Skip to content

Commit

Permalink
feat: use metadata to retrieve root CAs for verifying U2F attestation
Browse files Browse the repository at this point in the history
  • Loading branch information
bdewater committed Aug 21, 2019
1 parent 905c771 commit b89cf5c
Show file tree
Hide file tree
Showing 8 changed files with 307 additions and 58 deletions.
34 changes: 34 additions & 0 deletions lib/webauthn/attestation_statement/fido_u2f.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,27 @@ def valid?(authenticator_data, client_data_hash)
valid_credential_public_key?(authenticator_data.credential.public_key) &&
valid_aaguid?(authenticator_data.attested_credential_data.raw_aaguid) &&
valid_signature?(authenticator_data, client_data_hash) &&
certificate_chain_trusted? &&
[WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC_OR_ATTCA, [attestation_certificate]]
end

def attestation_certificate_key_id
return @attestation_certificate_key_id if defined?(@attestation_certificate_key_id)

@attestation_certificate_key_id = begin
extension = attestation_certificate.extensions.detect { |ext| ext.oid == "subjectKeyIdentifier" }
return if extension.nil? || extension.critical?

sequence = OpenSSL::ASN1.decode(extension.to_der)
octet_string = sequence.detect do |value|
value.tag_class == :UNIVERSAL && value.tag == OpenSSL::ASN1::OCTET_STRING
end
return unless octet_string

OpenSSL::ASN1.decode(octet_string.value).value.unpack("H*")[0]
end
end

private

def valid_format?
Expand Down Expand Up @@ -51,6 +69,14 @@ def valid_signature?(authenticator_data, client_data_hash)
.verify(signature, verification_data(authenticator_data, client_data_hash))
end

def certificate_chain_trusted?
find_metadata
return false unless metadata_statement

trust_store = build_trust_store(metadata_statement.attestation_root_certificates)
trust_store.verify(attestation_certificate, attestation_certificate_chain[1..-1])
end

def verification_data(authenticator_data, client_data_hash)
"\x00" +
authenticator_data.rp_id_hash +
Expand All @@ -62,6 +88,14 @@ def verification_data(authenticator_data, client_data_hash)
def public_key_u2f(cose_key_data)
PublicKey.new(cose_key_data)
end

def find_metadata
key_id = attestation_certificate_key_id
return unless key_id

@metadata_entry = metadata_store.fetch_entry(attestation_certificate_key_id: key_id)
@metadata_statement = metadata_store.fetch_statement(attestation_certificate_key_id: key_id)
end
end
end
end
15 changes: 4 additions & 11 deletions lib/webauthn/authenticator_attestation_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ def aaguid
end
end

def attestation_certificate_key
raw_subject_key_identifier(attestation_statement.attestation_certificate)&.unpack("H*")&.[](0)
def attestation_certificate_key_id
if attestation_statement.respond_to?(:attestation_certificate_key_id)
attestation_statement.attestation_certificate_key_id
end
end

private
Expand All @@ -79,14 +81,5 @@ def valid_attested_credential?
def valid_attestation_statement?
@attestation_type, @attestation_trust_path = attestation_statement.valid?(authenticator_data, client_data.hash)
end

def raw_subject_key_identifier(certificate)
extension = certificate.extensions.detect { |ext| ext.oid == "subjectKeyIdentifier" }
return unless extension

ext_asn1 = OpenSSL::ASN1.decode(extension.to_der)
ext_value = ext_asn1.value.last
OpenSSL::ASN1.decode(ext_value.value).value
end
end
end
36 changes: 31 additions & 5 deletions lib/webauthn/metadata/store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,32 @@ module Metadata
class Store
METADATA_ENDPOINT = URI("https://mds2.fidoalliance.org/")

def fetch_entry(aaguid:)
table_of_contents.entries.detect { |entry| entry.aaguid == aaguid }
def fetch_entry(aaguid: nil, attestation_certificate_key_id: nil)
verify_arguments(aaguid: aaguid, attestation_certificate_key_id: attestation_certificate_key_id)

if aaguid
table_of_contents.entries.detect { |entry| entry.aaguid == aaguid }
elsif attestation_certificate_key_id
table_of_contents.entries.detect do |entry|
entry.attestation_certificate_key_identifiers&.detect do |id|
id == attestation_certificate_key_id
end
end
end
end

def fetch_statement(aaguid:)
key = "statement_#{aaguid}"
def fetch_statement(aaguid: nil, attestation_certificate_key_id: nil)
verify_arguments(aaguid: aaguid, attestation_certificate_key_id: attestation_certificate_key_id)

key = "statement_#{aaguid || attestation_certificate_key_id}"
statement = cache_backend.read(key)
return statement if statement

entry = fetch_entry(aaguid: aaguid)
entry = if aaguid
fetch_entry(aaguid: aaguid)
elsif attestation_certificate_key_id
fetch_entry(attestation_certificate_key_id: attestation_certificate_key_id)
end
return unless entry

json = client.download_entry(entry.url, expected_hash: entry.hash)
Expand All @@ -28,6 +44,16 @@ def fetch_statement(aaguid:)

private

def verify_arguments(aaguid: nil, attestation_certificate_key_id: nil)
unless aaguid || attestation_certificate_key_id
raise ArgumentError, "must pass either aaguid or attestation_certificate_key"
end

if aaguid && attestation_certificate_key_id
raise ArgumentError, "cannot pass both aaguid and attestation_certificate_key"
end
end

def cache_backend
WebAuthn.configuration.cache_backend || raise("no cache_backend configured")
end
Expand Down
Binary file modified spec/conformance/metadata.zip
Binary file not shown.
11 changes: 11 additions & 0 deletions spec/support/feitian_ft_fido_0200.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBfjCCASWgAwIBAgIBATAKBggqhkjOPQQDAjAXMRUwEwYDVQQDDAxGVCBGSURP
IDAyMDAwIBcNMTYwNTAxMDAwMDAwWhgPMjA1MDA1MDEwMDAwMDBaMBcxFTATBgNV
BAMMDEZUIEZJRE8gMDIwMDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNBmrRqV
OxztTJVN19vtdqcL7tKQeol2nnM2/yYgvksZnr50SKbVgIEkzHQVOu80LVEE3lVh
eO1HjggxAlT6o4WjYDBeMB0GA1UdDgQWBBRJFWQt1bvG3jM6XgmV/IcjNtO/CzAf
BgNVHSMEGDAWgBRJFWQt1bvG3jM6XgmV/IcjNtO/CzAMBgNVHRMEBTADAQH/MA4G
A1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNHADBEAiAwfPqgIWIUB+QBBaVGsdHy
0s5RMxlkzpSX/zSyTZmUpQIgB2wJ6nZRM8oX/nA43Rh6SJovM2XwCCH//+LirBAb
B0M=
-----END CERTIFICATE-----
79 changes: 76 additions & 3 deletions spec/webauthn/attestation_statement/fido_u2f_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,85 @@
let(:attestation_key) { OpenSSL::PKey::EC.new("prime256v1").generate_key }
let(:signature) { attestation_key.sign("SHA256", to_be_signed) }

let(:attestation_certificate_version) { 2 }
let(:attestation_certificate_subject) { "/C=UY/O=ACME/OU=Authenticator Attestation/CN=CN" }
let(:attestation_certificate_basic_constraints) { "CA:FALSE" }
let(:attestation_certificate_ski) { "0123456789abcdef0123456789abcdef01234567" }
let(:attestation_certificate_extensions) do
extension_factory = OpenSSL::X509::ExtensionFactory.new
[
extension_factory.create_extension("basicConstraints", attestation_certificate_basic_constraints, true),
extension_factory.create_extension("subjectKeyIdentifier", attestation_certificate_ski, false),
]
end
let(:attestation_certificate_start_time) { Time.now }
let(:attestation_certificate_end_time) { Time.now + 60 }
let(:attestation_certificate) do
certificate = OpenSSL::X509::Certificate.new
certificate.not_before = Time.now
certificate.not_after = Time.now + 60
certificate.version = attestation_certificate_version
certificate.subject = OpenSSL::X509::Name.parse(attestation_certificate_subject)
certificate.issuer = root_certificate.subject
certificate.not_before = attestation_certificate_start_time
certificate.not_after = attestation_certificate_end_time
certificate.public_key = attestation_key
certificate.extensions = attestation_certificate_extensions

certificate.sign(attestation_key, OpenSSL::Digest::SHA256.new)
certificate.sign(root_key, OpenSSL::Digest::SHA256.new)

certificate.to_der
end

let(:root_key) { OpenSSL::PKey::EC.new("prime256v1").generate_key }
let(:root_certificate_start_time) { Time.now }
let(:root_certificate_end_time) { Time.now + 60 }

let(:root_certificate) do
root_certificate = OpenSSL::X509::Certificate.new
root_certificate.version = attestation_certificate_version
root_certificate.subject = OpenSSL::X509::Name.parse("/DC=org/DC=fake-ca/CN=Fake CA")
root_certificate.issuer = root_certificate.subject
root_certificate.public_key = root_key
root_certificate.not_before = root_certificate_start_time
root_certificate.not_after = root_certificate_end_time

root_certificate.sign(root_key, OpenSSL::Digest::SHA256.new)

root_certificate
end

let(:statement) do
WebAuthn::AttestationStatement::FidoU2f.new(
"sig" => signature,
"x5c" => [attestation_certificate]
)
end

let(:metadata_statement_root_certificates) { [root_certificate] }
let(:metadata_attestation_certificate_key_ids) { [attestation_certificate_ski] }
let(:metadata_statement) do
statement = WebAuthn::Metadata::Statement.new
statement.attestation_certificate_key_identifiers = metadata_attestation_certificate_key_ids
statement.attestation_root_certificates = metadata_statement_root_certificates
statement
end
let(:metadata_statement_key) { "statement_#{attestation_certificate_ski}" }
let(:metadata_entry) do
entry = WebAuthn::Metadata::Entry.new
entry.attestation_certificate_key_identifiers = metadata_attestation_certificate_key_ids
entry
end
let(:metadata_toc_entries) { [metadata_entry] }
let(:metadata_toc) do
toc = WebAuthn::Metadata::TableOfContents.new
toc.entries = metadata_toc_entries
toc
end

before do
WebAuthn.configuration.cache_backend.write(metadata_statement_key, metadata_statement)
WebAuthn.configuration.cache_backend.write("metadata_toc", metadata_toc)
end

it "works if everything's fine" do
expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy
end
Expand Down Expand Up @@ -135,5 +196,17 @@
expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy
end
end

context "when the metadata cannot verify the attestation statement" do
context "because the attestation certificate key identifier is completely unknown" do
let(:metadata_toc_entries) { [] }

it "fails" do
WebAuthn.configuration.cache_backend.delete(metadata_statement_key)

expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy
end
end
end
end
end
32 changes: 29 additions & 3 deletions spec/webauthn/authenticator_attestation_response_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,34 @@
)
end

let(:attestation_certificate_key_id) { "f4b64a68c334e901b8e23c6e66e6866c31931f5d" }
let(:attestation_certificate_key_ids) { [attestation_certificate_key_id] }
let(:attestation_root_certificate) do
OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, "..", "support", "feitian_ft_fido_0200.pem")))
end
let(:metadata_statement) do
statement = WebAuthn::Metadata::Statement.new
statement.attestation_certificate_key_identifiers = attestation_certificate_key_ids
statement.attestation_root_certificates = [attestation_root_certificate]
statement
end
let(:metadata_entry) do
entry = WebAuthn::Metadata::Entry.new
entry.attestation_certificate_key_identifiers = attestation_certificate_key_ids
entry
end
let(:metadata_toc_entries) { [metadata_entry] }
let(:metadata_toc) do
toc = WebAuthn::Metadata::TableOfContents.new
toc.entries = metadata_toc_entries
toc
end

before do
WebAuthn.configuration.cache_backend.write("statement_#{attestation_certificate_key_id}", metadata_statement)
WebAuthn.configuration.cache_backend.write("metadata_toc", metadata_toc)
end

it "verifies" do
expect(attestation_response.verify(original_challenge)).to be_truthy
end
Expand All @@ -82,9 +110,7 @@
end

it "returns the attestation certificate key" do
expect(attestation_response.attestation_certificate_key).to(
eq("f4b64a68c334e901b8e23c6e66e6866c31931f5d")
)
expect(attestation_response.attestation_certificate_key_id).to eq(attestation_certificate_key_id)
end
end

Expand Down
Loading

0 comments on commit b89cf5c

Please sign in to comment.