module WebauthnTestHelper # Fixed EC P-256 key pair for WebAuthn tests. WEBAUTHN_PRIVATE_KEY = OpenSSL::PKey::EC.new( [ "307702010104201dd589de7210b3318620f32150e3012cce021519df1d6e9e01" \ "0471146d395cdca00a06082a8648ce3d030107a14403420004116847fe19e1ad" \ "4471ab9980d7ff9cc1e4c7cb7a3af00e082b64fcd84f5ae70114c2495eef16f" \ "542b5e57dd1b263661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*") ) # Pre-encoded COSE EC2/ES256 public key (CBOR) for the key above. # {1: 2, 3: -7, -1: 1, -2: , -3: } COSE_PUBLIC_KEY = [ "a5010203262001215820116847fe19e1ad4471ab9980d7ff9cc1" \ "e4c7cb7a3af00e082b64fcd84f5ae70122582014c2495eef16f542b5e57dd1b2" \ "63661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*") # CBOR prefix for {"fmt": "none", "attStmt": {}, "authData": bytes(164)}. # Auth data is always 164 bytes: rp_id_hash(32) + flags(1) + sign_count(4) # + aaguid(16) + credential_id_length(2) + credential_id(32) + cose_key(77). ATTESTATION_OBJECT_CBOR_PREFIX = [ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4" ].pack("H*") private def request_webauthn_challenge untenanted { post my_passkey_challenge_url } response.parsed_body["challenge"] end def webauthn_challenge ActionPack::WebAuthn::PublicKeyCredential::Options.new.challenge end def webauthn_private_key WEBAUTHN_PRIVATE_KEY end def build_attestation_params(challenge:) credential_id = SecureRandom.random_bytes(32) auth_data = build_attestation_auth_data(credential_id: credential_id) { passkey: { client_data_json: webauthn_client_data_json(challenge: challenge, type: "webauthn.create"), attestation_object: Base64.urlsafe_encode64(ATTESTATION_OBJECT_CBOR_PREFIX + auth_data, padding: false), transports: [ "internal" ] } } end def build_assertion_params(challenge:, credential:, sign_count: 1) client_data_json = webauthn_client_data_json(challenge: challenge, type: "webauthn.get") authenticator_data = build_assertion_auth_data(sign_count: sign_count) signature = webauthn_sign(authenticator_data, client_data_json) { passkey: { id: credential.credential_id, client_data_json: client_data_json, authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false), signature: Base64.urlsafe_encode64(signature, padding: false) } } end def webauthn_client_data_json(challenge:, type:) { challenge: challenge, origin: "http://www.example.com", type: type }.to_json end # Attestation auth data includes the attested credential (public key + credential ID). def build_attestation_auth_data(credential_id:) [ Digest::SHA256.digest("www.example.com"), # rp_id_hash [ 0x45 ].pack("C"), # flags: UP + UV + AT [ 0 ].pack("N"), # sign_count "\x00" * 16, # aaguid [ credential_id.bytesize ].pack("n"), # credential_id_length credential_id, COSE_PUBLIC_KEY ].join.b end # Assertion auth data is simpler — no attested credential. def build_assertion_auth_data(sign_count:) [ Digest::SHA256.digest("www.example.com"), [ 0x05 ].pack("C"), # flags: UP + UV [ sign_count ].pack("N") ].join.b end def webauthn_sign(authenticator_data, client_data_json) signed_data = authenticator_data + Digest::SHA256.digest(client_data_json) WEBAUTHN_PRIVATE_KEY.sign("SHA256", signed_data) end end