Skip to content

Commit

Permalink
feat: use metadata to retrieve root CAs for verifying packed attestation
Browse files Browse the repository at this point in the history
With this commit, all the metadata server tests in the FIDO conformance tools pass
  • Loading branch information
bdewater committed Aug 16, 2019
1 parent a732c72 commit cde7540
Show file tree
Hide file tree
Showing 17 changed files with 395 additions and 11 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@
/Gemfile.lock
/gemfiles/*.gemfile.lock
.byebug_history

/spec/conformance/metadata.zip
20 changes: 20 additions & 0 deletions lib/webauthn/attestation_statement/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "openssl"
require "webauthn/authenticator_data/attested_credential_data"
require "webauthn/error"
require "webauthn/metadata/store"

module WebAuthn
module AttestationStatement
Expand All @@ -18,6 +19,8 @@ class NotSupportedError < Error; end

AAGUID_EXTENSION_OID = "1.3.6.1.4.1.45724.1.1.4"

attr_reader :metadata_entry, :metadata_statement

def initialize(statement)
@statement = statement
end
Expand Down Expand Up @@ -67,6 +70,23 @@ def raw_ecdaa_key_id
def signature
statement["sig"]
end

def metadata_store
@metadata_store ||= WebAuthn::Metadata::Store.new
end

def find_metadata(aaguid)
@metadata_entry = metadata_store.fetch_entry_by_aaguid(aaguid)
@metadata_statement = metadata_store.fetch_statement_by_aaguid(aaguid)
end

def build_trust_store(root_certificates)
trust_store = OpenSSL::X509::Store.new
root_certificates.each do |certificate|
trust_store.add_cert(certificate)
end
trust_store
end
end
end
end
17 changes: 7 additions & 10 deletions lib/webauthn/attestation_statement/packed.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ def valid?(authenticator_data, client_data_hash)

valid_format? &&
valid_algorithm?(authenticator_data.credential) &&
valid_certificate_chain? &&
valid_ec_public_keys?(authenticator_data.credential) &&
meet_certificate_requirement? &&
matching_aaguid?(authenticator_data.attested_credential_data.raw_aaguid) &&
valid_signature?(authenticator_data, client_data_hash) &&
certificate_chain_trusted?(authenticator_data.attested_credential_data.aaguid) &&
attestation_type_and_trust_path
end

Expand All @@ -45,9 +45,13 @@ def check_unsupported_feature
end
end

def valid_certificate_chain?
def certificate_chain_trusted?(aaguid)
if attestation_certificate_chain
attestation_certificate_chain[1..-1].all? { |c| certificate_in_use?(c) }
find_metadata(aaguid)
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])
else
true
end
Expand All @@ -65,20 +69,13 @@ def meet_certificate_requirement?
subject = attestation_certificate.subject.to_a

attestation_certificate.version == 2 &&
certificate_in_use?(attestation_certificate) &&
subject.assoc('OU')&.at(1) == "Authenticator Attestation" &&
attestation_certificate.extensions.find { |ext| ext.oid == 'basicConstraints' }&.value == 'CA:FALSE'
else
true
end
end

def certificate_in_use?(certificate)
now = Time.now

certificate.not_before < now && now < certificate.not_after
end

def valid_signature?(authenticator_data, client_data_hash)
signature_verifier = WebAuthn::SignatureVerifier.new(
algorithm,
Expand Down
2 changes: 2 additions & 0 deletions lib/webauthn/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def self.if_pss_supported(algorithm)
attr_accessor :rp_name
attr_accessor :verify_attestation_statement
attr_accessor :credential_options_timeout
attr_accessor :metadata_token
attr_accessor :cache_backend

def initialize
@algorithms = DEFAULT_ALGORITHMS.dup
Expand Down
59 changes: 59 additions & 0 deletions lib/webauthn/metadata/store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

require "webauthn/metadata/client"
require "webauthn/metadata/table_of_contents"

module WebAuthn
module Metadata
class Store
METADATA_ENDPOINT = URI("https://mds2.fidoalliance.org/")

def fetch_entry_by_aaguid(aaguid)
table_of_contents.entries.detect do |entry|
entry.aaguid == aaguid
end
end

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

entry = fetch_entry_by_aaguid(aaguid)
return unless entry

json = client.download_entry(entry.url, expected_hash: entry.hash)
statement = WebAuthn::Metadata::Statement.from_json(json)
cache_backend.write(key, statement)
statement
end

private

def cache_backend
WebAuthn.configuration.cache_backend || raise("no cache_backend configured")
end

def metadata_token
WebAuthn.configuration.metadata_token || raise("no metadata_token configured")
end

def client
@client ||= WebAuthn::Metadata::Client.new(metadata_token)
end

def table_of_contents
@table_of_contents ||= begin
key = "metadata_toc"
toc = cache_backend.read(key)
return toc if toc

json = client.download_toc(METADATA_ENDPOINT)
toc = WebAuthn::Metadata::TableOfContents.from_json(json)
cache_backend.write(key, toc)
toc
end
end
end
end
end
1 change: 1 addition & 0 deletions spec/conformance/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ ruby "~> 2.6.0"

gem "byebug"
gem "rack-contrib"
gem "rubyzip"
gem "sinatra", "~> 2.0"
gem "sinatra-contrib"
gem "webauthn", path: File.join("..", "..")
2 changes: 2 additions & 0 deletions spec/conformance/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ GEM
rack (~> 2.0)
rack-protection (2.0.5)
rack
rubyzip (1.2.3)
securecompare (1.0.0)
sinatra (2.0.5)
mustermann (~> 1.0)
Expand All @@ -50,6 +51,7 @@ PLATFORMS
DEPENDENCIES
byebug
rack-contrib
rubyzip
sinatra (~> 2.0)
sinatra-contrib
webauthn!
Expand Down
3 changes: 3 additions & 0 deletions spec/conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ bundle exec ruby server.rb
```

Configure the FIDO2 Test Tool to use the following server URL: `http://localhost:4567` and run any of the server tests.

For running the Metadata Service Tests, click "Download server metadata" and store the file in the same directory as
`server.rb` before starting the server.
56 changes: 56 additions & 0 deletions spec/conformance/conformance_cache_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

require "zip"
require_relative "../support/test_cache_store"

class ConformanceCacheStore < TestCacheStore
def setup_authenticators
filename = "metadata.zip"
puts("#{filename} not found, this will affect Metadata Service Test results.") unless File.exist?(filename)

Zip::File.open(filename).glob("metadataStatements/*.json") do |file|
json = JSON.parse(file.get_input_stream.read)
statement = WebAuthn::Metadata::Statement.from_json(json)
write("statement_#{statement.aaguid}", statement)
end
end

def setup_metadata_store
puts("Setting up metadata store TOC")
response = Net::HTTP.post(
URI("https://fidoalliance.co.nz/mds/getEndpoints"),
{ endpoint: WebAuthn.configuration.origin }.to_json,
WebAuthn::Metadata::Client::DEFAULT_HEADERS
)
response.value
possible_endpoints = JSON.parse(response.body)["result"]

client = WebAuthn::Metadata::Client.new(nil)
json = possible_endpoints.each_with_index do |uri, index|
begin
puts("Trying endpoint #{index}: #{uri}")
break client.download_toc(URI(uri), trust_store: conformance_trust_store)
rescue WebAuthn::Metadata::Client::DataIntegrityError, JWT::VerificationError
nil
end
end

if json.is_a?(Hash) && json.keys == ["legalHeader", "no", "nextUpdate", "entries"]
puts("TOC setup done!")
toc = WebAuthn::Metadata::TableOfContents.from_json(json)
write("metadata_toc", toc)
else
puts("Unable to setup TOC!")
end
end

private

def conformance_trust_store
store = OpenSSL::X509::Store.new
store.purpose = OpenSSL::X509::PURPOSE_ANY
store.flags = OpenSSL::X509::V_FLAG_CRL_CHECK | OpenSSL::X509::V_FLAG_CRL_CHECK_ALL
file = File.read(File.join(__dir__, "..", "support", "MDSROOT.crt"))
store.add_cert(OpenSSL::X509::Certificate.new(file))
end
end
Binary file added spec/conformance/metadata.zip
Binary file not shown.
19 changes: 19 additions & 0 deletions spec/conformance/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
require "sinatra/cookies"
require "byebug"

require_relative "conformance_cache_store"

use Rack::PostBodyContentTypeParser
set show_exceptions: false

RP_NAME = "webauthn-ruby #{WebAuthn::VERSION} conformance test server"
UNACCEPTABLE_STATUSES = ["USER_VERIFICATION_BYPASS", "ATTESTATION_KEY_COMPROMISE", "USER_KEY_REMOTE_COMPROMISE",
"USER_KEY_PHYSICAL_COMPROMISE", "REVOKED"].freeze

Credential = Struct.new(:id, :public_key, :sign_count) do
@credentials = {}
Expand All @@ -36,6 +40,10 @@ def descriptor
config.origin = "http://#{host}:#{settings.port}"
config.rp_name = RP_NAME
config.algorithms.concat(%w(ES384 ES512 PS384 PS512 RS384 RS512 RS1))
config.metadata_token = ""
config.cache_backend = ConformanceCacheStore.new
config.cache_backend.setup_authenticators
config.cache_backend.setup_metadata_store
end

post "/attestation/options" do
Expand All @@ -62,6 +70,9 @@ def descriptor
expected_challenge = Base64.urlsafe_decode64(cookies["challenge"])
public_key_credential.verify(expected_challenge)

metadata_entry = public_key_credential.response.attestation_statement.metadata_entry
verify_authenticator_status(metadata_entry)

Credential.register(
cookies["username"],
id: public_key_credential.id,
Expand Down Expand Up @@ -133,3 +144,11 @@ def render_ok(params = {})
def render_error(message)
JSON.dump(status: "error", errorMessage: message)
end

def verify_authenticator_status(entry)
return unless entry

raise("bad authenticator status") if entry.status_reports.any? do |status_report|
UNACCEPTABLE_STATUSES.include?(status_report.status)
end
end
7 changes: 7 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

require "byebug"
require "webauthn/fake_client"
require_relative "support/test_cache_store"
require "webmock/rspec"

RSpec.configure do |config|
Expand All @@ -19,6 +20,12 @@
c.syntax = :expect
end

config.before do
WebAuthn.configure do |webauthn_config|
webauthn_config.cache_backend = TestCacheStore.new
end
end

config.after do
WebAuthn.instance_variable_set(:@configuration, nil)
end
Expand Down
24 changes: 24 additions & 0 deletions spec/support/test_cache_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

# A very simple cache story for the test suite that mimics the ActiveSupport::Cache::Store interface
class TestCacheStore
def initialize
@store = {}
end

def read(name, _options = nil)
@store[name]
end

def write(name, value, _options = nil)
@store[name] = value
end

def delete(name, _options = nil)
@store.delete(name)
end

def clear(_options = nil)
@store.clear
end
end
19 changes: 19 additions & 0 deletions spec/support/yubico_u2f_root.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZ
dWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAw
MDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290
IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk
5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep
8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbw
nebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT
9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXw
LvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJ
hjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAN
BgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4
MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kt
hX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2k
LVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1U
sG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqc
U9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw==
-----END CERTIFICATE-----
Loading

0 comments on commit cde7540

Please sign in to comment.