Last active
May 30, 2019 17:23
-
-
Save bdewater/e3b7b213302845ffac8906eef971f0af to your computer and use it in GitHub Desktop.
Migrating from U2F to WebAuthn
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Migrating from https://github.com/castle/ruby-u2f to https://github.com/cedarcode/webauthn-ruby | |
# Also see FIDO CTAP2 specs on backwards compatibility with CTAP1/U2F authenticators: | |
# https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-client-to-authenticator-protocol-v2.0-rd-20180702.html#u2f-authenticatorMakeCredential-interoperability | |
# Chrome team encouraging to migrate: https://groups.google.com/a/chromium.org/forum/#!msg/security-dev/BGWA1d7a6rI/W2avestmBAAJ | |
require 'u2f' | |
require 'webauthn' | |
require 'webauthn/fake_client' | |
require 'webauthn/attestation_statement/fido_u2f' | |
domain = URI("https://login.example.com") | |
# Use a Fake U2F device to create a registration like an application would store in the database | |
u2f_device = U2F::FakeU2F.new(domain.to_s) | |
U2F_PRIVATE_KEY = u2f_device.send(:origin_key) | |
u2f_challenge = U2F.urlsafe_encode64(SecureRandom.random_bytes(32)) | |
u2f_register_challenge = u2f_device.register_response(u2f_challenge) | |
u2f_register_response = U2F::RegisterResponse.load_from_json(u2f_register_challenge) | |
u2f_registration = U2F::U2F.new(domain.to_s).register!(u2f_challenge, u2f_register_response) | |
# <U2F::Registration:0x00007fb815b4ebf8 | |
# @certificate= | |
# "MIIBCzCBsgIBATAKBggqhkjOPQQDAjASMRAwDgYDVQQDDAdVMkZUZXN0MB4XDTE5MDUyOTE4MDQwOVoXDTIwMDUyODE4MDQwOVowEjEQMA4GA1UEAwwHVTJGVGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIn18j1TysGWNO7Rl84dD1nPTysGAnnS39r3J3DrYW8SWMVf1bYn/cjvb4pfsUhvxVK+CNkAkKoTTmot4As9PGQwCgYIKoZIzj0EAwIDSAAwRQIgNR23ORnxC3zaJrsQQiv3Ule/xFPFYG3Zgfb9nrwFgAwCIQCuLWxCUOu8SVlPYrDc/ivZ32PpnjjVRfTN6sybj/DeyQ==", | |
# @key_handle="Y58TQlujqbWrUnMW7JPqGXrENd5YyZJ_aIh9-H84WtA", | |
# @public_key="BHljYElOPoJrhYqflKPvjqXTH8F5R9sG3hutKbZ74b0n9ALOfslwDNPNzoSL+YPcyPdrtQII4TzN+9O5K4jXEfk="> | |
# A class that quacks like an WebAuthn::AuthenticatorAttestationResponse object, mimicking a real WebAuthn registration | |
class U2fCredentialMigrator | |
def initialize(domain:, u2f_certificate:, u2f_key_handle:, u2f_public_key:, u2f_counter:) | |
@domain = domain | |
@u2f_certificate = u2f_certificate | |
@u2f_key_handle = u2f_key_handle | |
@u2f_public_key = u2f_public_key | |
@u2f_counter = u2f_counter | |
end | |
def authenticator_data | |
@authenticator_data ||= WebAuthn::FakeAuthenticator::AuthenticatorData.new( | |
rp_id_hash: OpenSSL::Digest::SHA256.digest(@domain.host), | |
credential: { | |
id: credential_id, | |
public_key: credential_cose_key | |
}, | |
sign_count: @u2f_count, | |
user_present: true, | |
user_verified: false, | |
aaguid: WebAuthn::AttestationStatement::FidoU2f::VALID_ATTESTED_AAGUID, | |
# extensions: nil | |
) | |
end | |
def credential | |
@credential ||= begin | |
hash = authenticator_data.send(:credential) | |
WebAuthn::AuthenticatorData::AttestedCredentialData::Credential.new(hash[:id], hash[:public_key].serialize) | |
end | |
end | |
def attestation_type | |
WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC_OR_ATTCA | |
end | |
def attestation_trust_path | |
@attestation_trust_path ||= [OpenSSL::X509::Certificate.new(Base64.strict_decode64(@u2f_certificate))] | |
end | |
private | |
# Let credentialId be a credentialIdLength byte array initialized with CTAP1/U2F response key handle bytes. | |
def credential_id | |
Base64.urlsafe_decode64(@u2f_key_handle) | |
end | |
# Let x9encodedUserPublicKey be the user public key returned in the U2F registration response message [U2FRawMsgs]. | |
# Let coseEncodedCredentialPublicKey be the result of converting x9encodedUserPublicKey’s value from ANS X9.62 / | |
# Sec-1 v2 uncompressed curve point representation [SEC1V2] to COSE_Key representation ([RFC8152] Section 7). | |
def credential_cose_key | |
decoded_public_key = Base64.strict_decode64(@u2f_public_key) | |
if WebAuthn::AttestationStatement::FidoU2f::PublicKey.uncompressed_point?(decoded_public_key) | |
COSE::Key::EC2.new( | |
alg: COSE::Algorithm.by_name("ES256").id, | |
crv: 1, | |
x: decoded_public_key[1..32], | |
y: decoded_public_key[33..-1] | |
) | |
else | |
raise "expected U2F public key to be in uncompressed point format" | |
end | |
end | |
end | |
# Use U2fMigratedAuthenticatorAssertion like a real response, following from the gem readme: | |
# > Keep Credential ID and Credential Public Key under storage for future authentications | |
# > Access by invoking: | |
# > `attestation_response.credential.id` | |
# > `attestation_response.credential.public_key` | |
MIGRATION_RESPONSE = U2fCredentialMigrator.new( | |
domain: domain, | |
u2f_certificate: u2f_registration.certificate, | |
u2f_key_handle: u2f_registration.key_handle, | |
u2f_public_key: u2f_registration.public_key, | |
u2f_counter: u2f_registration.counter | |
) | |
MIGRATION_RESPONSE.credential | |
# <struct WebAuthn::AuthenticatorData::AttestedCredentialData::Credential | |
# id="c\x9F\x13B[\xA3\xA9\xB5\xABRs\x16\xEC\x93\xEA\x19z\xC45\xDEX\xC9\x92\x7Fh\x88}\xF8\x7F8Z\xD0", | |
# public_key= | |
# #<COSE::Key::EC2:0x00007fb815a08b90 | |
# @alg=-7, | |
# @base_iv=nil, | |
# @crv=1, | |
# @d=nil, | |
# @key_ops=nil, | |
# @kid=nil, | |
# @x="yc`IN>\x82k\x85\x8A\x9F\x94\xA3\xEF\x8E\xA5\xD3\x1F\xC1yG\xDB\x06\xDE\e\xAD)\xB6{\xE1\xBD'", | |
# @y="\xF4\x02\xCE~\xC9p\f\xD3\xCD\xCE\x84\x8B\xF9\x83\xDC\xC8\xF7k\xB5\x02\b\xE1<\xCD\xFB\xD3\xB9+\x88\xD7\x11\xF9">> | |
# | |
# Quick 'n dirty monkey patch to inject our U2F data into the FakeAuthenticator | |
class U2FMigratedFakeAuthenticator < WebAuthn::FakeAuthenticator | |
private | |
def new_credential | |
[MIGRATION_RESPONSE.credential.id, U2F_PRIVATE_KEY] | |
end | |
end | |
# Register our migrated authenticator | |
fake_authenticator = U2FMigratedFakeAuthenticator.new | |
fake_client = WebAuthn::FakeClient.new(domain.to_s, authenticator: fake_authenticator) | |
fake_client.create(user_present: true, user_verified: false) | |
# Authenticate with the migrated authenticator | |
webauthn_challenge = SecureRandom.random_bytes(32) | |
fake_response = fake_client.get(challenge: webauthn_challenge, user_present: true, user_verified: false) | |
assertion_response = WebAuthn::AuthenticatorAssertionResponse.new( | |
credential_id: MIGRATION_RESPONSE.credential.id, | |
authenticator_data: fake_response[:response][:authenticator_data], | |
client_data_json: fake_response[:response][:client_data_json], | |
signature: fake_response[:response][:signature] | |
) | |
allowed_credential = { id: MIGRATION_RESPONSE.credential.id, public_key: MIGRATION_RESPONSE.credential.public_key } | |
assertion_response.verify(webauthn_challenge, domain.to_s, allowed_credentials: [allowed_credential]) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment