canonical text] or ['error' => 'encoding'|'version'|'key']. * * The ODrop app's Copy button hands over the whole launcher line rather than the bare card, so a * pasted line is read for its data-card value. Anything else is taken as the card itself. */ function odrop_support_read_card(string $pasted): array { $text = trim($pasted); if (preg_match('/data-card\s*=\s*["\']?([^"\'\s>]*)/i', $text, $found)) { $text = $found[1]; } $bytes = odrop_support_base64url_decode($text); // A NEWER version is told apart from junk at any length, because a newer card may be longer; // version 0 never existed, so it is junk. Same order as the app. if ($bytes === null || $bytes === '' || ord($bytes[0]) === 0) { return ['error' => 'encoding']; } if (ord($bytes[0]) > ODROP_SUPPORT_CARD_VERSION) { return ['error' => 'version']; } if (strlen($bytes) !== ODROP_SUPPORT_CARD_BYTES) { return ['error' => 'encoding']; } // A low-order key would make every knock openable by anyone who read the card. if (odrop_support_x25519_low_order(substr($bytes, 2, 32))) { return ['error' => 'key']; } return ['card' => odrop_support_base64url_encode($bytes)]; } /** * True when $card is 67 base64url characters: the length of a version-1 card, and nothing that * can leave the attribute it is printed into. A cheap check for output, not a parse. */ function odrop_support_card_shape_ok(string $card): bool { return (bool) preg_match('/^[A-Za-z0-9_-]{67}$/', $card); } /** * Unpadded base64url, decoded the way the ODrop app decodes a card: the url alphabet only, no * padding, a length of 1 mod 4 refused, spare low bits of the last character ignored. Returns * null for anything it refuses. */ function odrop_support_base64url_decode(string $text): ?string { $length = strlen($text); if ($length === 0 || $length % 4 === 1 || !preg_match('/^[A-Za-z0-9_-]+$/', $text)) { return null; } $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; $out = ''; foreach (str_split($text, 4) as $chunk) { $n = 0; for ($i = 0; $i < 4; $i++) { $n = ($n << 6) | ($i < strlen($chunk) ? strpos($alphabet, $chunk[$i]) : 0); } $out .= chr(($n >> 16) & 0xff); if (strlen($chunk) > 2) { $out .= chr(($n >> 8) & 0xff); } if (strlen($chunk) > 3) { $out .= chr($n & 0xff); } } return $out; } /** Unpadded base64url, the spelling the ODrop app writes a card in. */ function odrop_support_base64url_encode(string $bytes): string { return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); } /** * True when an X25519 public key has small order, so that every shared secret made with it is * zero - the same question the app asks by checking that a key exchange was contributory. * * The seven u-coordinates below are the whole set (0, 1, the two points of order 8, p-1, p and * p+1), compared with the top bit masked because X25519 ignores it. Each one, with and without the * top bit, gives a zero output from OpenSSL's X25519. */ function odrop_support_x25519_low_order(string $key): bool { if (strlen($key) !== 32) { return true; } $masked = substr($key, 0, 31) . chr(ord($key[31]) & 0x7f); $low_order = [ '0000000000000000000000000000000000000000000000000000000000000000', '0100000000000000000000000000000000000000000000000000000000000000', 'e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800', '5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157', 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', 'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', 'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', ]; foreach ($low_order as $hex) { if (hash_equals((string) hex2bin($hex), $masked)) { return true; } } return false; }