Skip to content

Commit 193bb03

Browse files
authored
Merge pull request #343 from cedarcode/apple_attestation_format
Support 'apple' attestation statement format
2 parents f787a2f + eedc838 commit 193bb03

File tree

10 files changed

+209
-8
lines changed

10 files changed

+209
-8
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ credential.authenticator_extension_outputs
417417
| tpm (x5c attestation) | Yes |
418418
| android-key | Yes |
419419
| android-safetynet | Yes |
420+
| apple | Yes |
420421
| fido-u2f | Yes |
421422
| none | Yes |
422423

lib/webauthn/attestation_statement.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "webauthn/attestation_statement/android_key"
44
require "webauthn/attestation_statement/android_safetynet"
5+
require "webauthn/attestation_statement/apple"
56
require "webauthn/attestation_statement/fido_u2f"
67
require "webauthn/attestation_statement/none"
78
require "webauthn/attestation_statement/packed"
@@ -18,14 +19,16 @@ class FormatNotSupportedError < Error; end
1819
ATTESTATION_FORMAT_ANDROID_SAFETYNET = "android-safetynet"
1920
ATTESTATION_FORMAT_ANDROID_KEY = "android-key"
2021
ATTESTATION_FORMAT_TPM = "tpm"
22+
ATTESTATION_FORMAT_APPLE = "apple"
2123

2224
FORMAT_TO_CLASS = {
2325
ATTESTATION_FORMAT_NONE => WebAuthn::AttestationStatement::None,
2426
ATTESTATION_FORMAT_FIDO_U2F => WebAuthn::AttestationStatement::FidoU2f,
2527
ATTESTATION_FORMAT_PACKED => WebAuthn::AttestationStatement::Packed,
2628
ATTESTATION_FORMAT_ANDROID_SAFETYNET => WebAuthn::AttestationStatement::AndroidSafetynet,
2729
ATTESTATION_FORMAT_ANDROID_KEY => WebAuthn::AttestationStatement::AndroidKey,
28-
ATTESTATION_FORMAT_TPM => WebAuthn::AttestationStatement::TPM
30+
ATTESTATION_FORMAT_TPM => WebAuthn::AttestationStatement::TPM,
31+
ATTESTATION_FORMAT_APPLE => WebAuthn::AttestationStatement::Apple
2932
}.freeze
3033

3134
def self.from(format, statement)

lib/webauthn/attestation_statement/android_key.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ def valid?(authenticator_data, client_data_hash)
2020

2121
private
2222

23-
def matching_public_key?(authenticator_data)
24-
attestation_certificate.public_key.to_der == authenticator_data.credential.public_key_object.to_der
25-
end
26-
2723
def valid_attestation_challenge?(client_data_hash)
2824
android_key_attestation.verify_challenge(client_data_hash)
2925
rescue AndroidKeyAttestation::ChallengeMismatchError
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# frozen_string_literal: true
2+
3+
require "openssl"
4+
require "webauthn/attestation_statement/base"
5+
6+
module WebAuthn
7+
module AttestationStatement
8+
class Apple < Base
9+
# Source: https://www.apple.com/certificateauthority/private/
10+
ROOT_CERTIFICATE =
11+
OpenSSL::X509::Certificate.new(<<~PEM)
12+
-----BEGIN CERTIFICATE-----
13+
MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w
14+
HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ
15+
bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx
16+
NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG
17+
A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49
18+
AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k
19+
xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/
20+
pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk
21+
2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA
22+
MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3
23+
jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B
24+
1bWeT0vT
25+
-----END CERTIFICATE-----
26+
PEM
27+
28+
NONCE_EXTENSION_OID = "1.2.840.113635.100.8.2"
29+
30+
def valid?(authenticator_data, client_data_hash)
31+
valid_nonce?(authenticator_data, client_data_hash) &&
32+
matching_public_key?(authenticator_data) &&
33+
trustworthy? &&
34+
[attestation_type, attestation_trust_path]
35+
end
36+
37+
private
38+
39+
def valid_nonce?(authenticator_data, client_data_hash)
40+
extension = cred_cert&.extensions&.detect { |ext| ext.oid == NONCE_EXTENSION_OID }
41+
42+
if extension
43+
sequence = OpenSSL::ASN1.decode(OpenSSL::ASN1.decode(extension.to_der).value[1].value)
44+
45+
sequence.tag == OpenSSL::ASN1::SEQUENCE &&
46+
sequence.value.size == 1 &&
47+
sequence.value[0].value[0].value ==
48+
OpenSSL::Digest::SHA256.digest(authenticator_data.data + client_data_hash)
49+
end
50+
end
51+
52+
def attestation_type
53+
WebAuthn::AttestationStatement::ATTESTATION_TYPE_ANONCA
54+
end
55+
56+
def cred_cert
57+
attestation_certificate
58+
end
59+
60+
def default_root_certificates
61+
[ROOT_CERTIFICATE]
62+
end
63+
end
64+
end
65+
end

lib/webauthn/attestation_statement/base.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ class UnsupportedAlgorithm < Error; end
1616
ATTESTATION_TYPE_SELF = "Self"
1717
ATTESTATION_TYPE_ATTCA = "AttCA"
1818
ATTESTATION_TYPE_BASIC_OR_ATTCA = "Basic_or_AttCA"
19+
ATTESTATION_TYPE_ANONCA = "AnonCA"
1920

2021
ATTESTATION_TYPES_WITH_ROOT = [
2122
ATTESTATION_TYPE_BASIC,
2223
ATTESTATION_TYPE_BASIC_OR_ATTCA,
23-
ATTESTATION_TYPE_ATTCA
24+
ATTESTATION_TYPE_ATTCA,
25+
ATTESTATION_TYPE_ANONCA
2426
].freeze
2527

2628
class Base
@@ -62,6 +64,10 @@ def matching_aaguid?(attested_credential_data_aaguid)
6264
end
6365
end
6466

67+
def matching_public_key?(authenticator_data)
68+
attestation_certificate.public_key.to_der == authenticator_data.credential.public_key_object.to_der
69+
end
70+
6571
def certificates
6672
@certificates ||=
6773
raw_certificates&.map do |raw_certificate|

lib/webauthn/configuration.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def initialize
3535
@verify_attestation_statement = true
3636
@credential_options_timeout = 120000
3737
@silent_authentication = false
38-
@acceptable_attestation_types = ['None', 'Self', 'Basic', 'AttCA', 'Basic_or_AttCA']
38+
@acceptable_attestation_types = ['None', 'Self', 'Basic', 'AttCA', 'Basic_or_AttCA', 'AnonCA']
3939
@attestation_root_certificates_finders = []
4040
end
4141

spec/spec_helper.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ def issue_certificate(
141141
not_after: Time.now + 60,
142142
extensions: nil
143143
)
144-
145144
certificate = OpenSSL::X509::Certificate.new
146145

147146
certificate.version = version
@@ -159,3 +158,11 @@ def issue_certificate(
159158

160159
certificate
161160
end
161+
162+
def fake_certificate_chain_validation_time(attestation_statement, time)
163+
allow(attestation_statement).to receive(:attestation_root_certificates_store).and_wrap_original do |m, *args|
164+
store = m.call(*args)
165+
store.time = time
166+
store
167+
end
168+
end

spec/support/seeds.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ def seeds
7373
authenticator_data: "wqc1M3OySstQSIGfoFIjkPhIJrGaCJiQKPeryg70zSsBAAAAbQ=="
7474
}
7575
}
76+
},
77+
macbook_touch_id: {
78+
origin: "http://localhost:3000",
79+
credential_creation_options: {
80+
challenge: "a8mMXGbnWYzB2RG1cTu96rhyXewrZgHR_34BuIuRYTE"
81+
},
82+
authenticator_attestation_response: {
83+
attestation_object: "o2NmbXRlYXBwbGVnYXR0U3RtdKFjeDVjglkCRzCCAkMwggHJoAMCAQICBgF3z8QYXDAKBggqhkjOPQQDAjBIMRwwGgYDVQQDDBNBcHBsZSBXZWJBdXRobiBDQSAxMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIxMDIyMjE2NDExMVoXDTIxMDIyNTE2NDExMVowgZExSTBHBgNVBAMMQGUwZWM5MjFiOGNkMGYxNGU3ODUzZjUzYThlNDU3NmRkY2U3OWJhN2UwNDkwMjk5OWQ2M2VlOWU2NGIyYmRlZjcxGjAYBgNVBAsMEUFBQSBDZXJ0aWZpY2F0aW9uMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPBcyVz0vF1QSjcsDdPY7WJrx0jvFh_dZnj56ytGIrdddJz5QcWIqZrEB0csxZFNjPFK0hQooZYvTixgBt7D5kqNVMFMwDAYDVR0TAQH_BAIwADAOBgNVHQ8BAf8EBAMCBPAwMwYJKoZIhvdjZAgCBCYwJKEiBCCoFqZ2h1zWBGc8uBsh0z02Ikn7eWVoI8W9OjiRWsQWUjAKBggqhkjOPQQDAgNoADBlAjEA54GIEOvNG3mjCymslbIVwg-tendQ-hRc3PCwcyVVLBdReEnoMiHCAYmh1xCvWV8KAjBkYJa8dnVlNBF92WtuVWL7IrBd5gzGd55roG9U0H7RJm5QC6DPvRdaNl2lnpxdWzZZAjgwggI0MIIBuqADAgECAhBWJVOVx6f7QOviKNgmCFO2MAoGCCqGSM49BAMDMEsxHzAdBgNVBAMMFkFwcGxlIFdlYkF1dGhuIFJvb3QgQ0ExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwHhcNMjAwMzE4MTgzODAxWhcNMzAwMzEzMDAwMDAwWjBIMRwwGgYDVQQDDBNBcHBsZSBXZWJBdXRobiBDQSAxMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEgy6HLyYUkYECJbn1_Na7Y3i19V8_ywRbxzWZNHX9VJBE35v-GSEXZcaaHdoFCzjUUINAGkNPsk0RLVbD4c-_y5iR_sBpYIG--Wy8d8iN3a9Gpa7h3VFbWvqrk76cCyaRo2YwZDASBgNVHRMBAf8ECDAGAQH_AgEAMB8GA1UdIwQYMBaAFCbXZNnFeMJaZ9Gn3msS0Btj8cbXMB0GA1UdDgQWBBTrroLE_6GsW1HUzyRhBQC-Y713iDAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIxAN2LGjSBpfrZ27TnZXuEHhRMJ7dbh2pBhsKxR1dQM3In7-VURX72SJUMYy5cSD5wwQIwLIpgRNwgH8_lm8NNKTDBSHhR2WDtanXx60rKvjjNJbiX0MgFvvDH94sHpXHG6A4HaGF1dGhEYXRhWJhJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0UAAAAAAAAAAAAAAAAAAAAAAAAAAAAUGNsngcxQiY1p2BB7fCpOWHzbTeilAQIDJiABIVggPBcyVz0vF1QSjcsDdPY7WJrx0jvFh_dZnj56ytGIrdciWCBdJz5QcWIqZrEB0csxZFNjPFK0hQooZYvTixgBt7D5kg",
84+
client_data_json: "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYThtTVhHYm5XWXpCMlJHMWNUdTk2cmh5WGV3clpnSFJfMzRCdUl1UllURSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCJ9"
85+
}
7686
}
7787
}
7888
end
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
require "openssl"
6+
require "webauthn/attestation_statement/apple"
7+
8+
RSpec.describe "Apple attestation" do
9+
describe "#valid?" do
10+
let(:credential_key) { create_ec_key }
11+
let(:root_key) { create_ec_key }
12+
let(:root_certificate) { create_root_certificate(root_key) }
13+
14+
let(:cred_cert) do
15+
issue_certificate(root_certificate, root_key, credential_key, extensions: [cred_cert_extension])
16+
end
17+
18+
let(:statement) { WebAuthn::AttestationStatement::Apple.new("x5c" => [cred_cert.to_der]) }
19+
20+
let(:authenticator_data_bytes) do
21+
WebAuthn::FakeAuthenticator::AuthenticatorData.new(
22+
rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"),
23+
credential: { id: "0".b * 16, public_key: credential_key.public_key }
24+
).serialize
25+
end
26+
27+
let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) }
28+
let(:client_data_hash) { OpenSSL::Digest::SHA256.digest({}.to_json) }
29+
30+
let(:nonce) { Digest::SHA256.digest(authenticator_data.data + client_data_hash) }
31+
let(:cred_cert_extension) do
32+
OpenSSL::X509::Extension.new(
33+
"1.2.840.113635.100.8.2",
34+
OpenSSL::ASN1::Sequence.new(
35+
[OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::OctetString.new(nonce)])]
36+
)
37+
)
38+
end
39+
40+
around do |example|
41+
silence_warnings do
42+
original_apple_certificate = WebAuthn::AttestationStatement::Apple::ROOT_CERTIFICATE
43+
WebAuthn::AttestationStatement::Apple::ROOT_CERTIFICATE = root_certificate
44+
example.run
45+
WebAuthn::AttestationStatement::Apple::ROOT_CERTIFICATE = original_apple_certificate
46+
end
47+
end
48+
49+
it "works if everything's fine" do
50+
expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy
51+
end
52+
53+
context "when nonce is invalid" do
54+
let(:nonce) { Digest::SHA256.digest("Invalid") }
55+
56+
it "fails" do
57+
expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy
58+
end
59+
end
60+
61+
context "when the credential public key is invalid" do
62+
let(:cred_cert) do
63+
issue_certificate(root_certificate, root_key, create_ec_key, extensions: [cred_cert_extension])
64+
end
65+
66+
it "fails" do
67+
expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy
68+
end
69+
end
70+
end
71+
end

spec/webauthn/authenticator_attestation_response_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,48 @@
328328
end
329329
end
330330

331+
context "when apple attestation" do
332+
let(:origin) { seeds[:macbook_touch_id][:origin] }
333+
334+
let(:original_challenge) do
335+
Base64.urlsafe_decode64(seeds[:macbook_touch_id][:credential_creation_options][:challenge])
336+
end
337+
338+
let(:attestation_response) do
339+
response = seeds[:macbook_touch_id][:authenticator_attestation_response]
340+
341+
WebAuthn::AuthenticatorAttestationResponse.new(
342+
attestation_object: Base64.urlsafe_decode64(response[:attestation_object]),
343+
client_data_json: Base64.urlsafe_decode64(response[:client_data_json])
344+
)
345+
end
346+
347+
before do
348+
# Apple credential certificate expires after 3 days apparently.
349+
# Seed data was obtained 22nd Feb 2021, so we are simulating validation within that 3 day timeframe
350+
fake_certificate_chain_validation_time(attestation_response.attestation_statement, Time.parse("2021-02-23"))
351+
end
352+
353+
it "verifies" do
354+
expect(attestation_response.verify(original_challenge)).to be_truthy
355+
end
356+
357+
it "is valid" do
358+
expect(attestation_response.valid?(original_challenge)).to eq(true)
359+
end
360+
361+
it "returns attestation info" do
362+
attestation_response.valid?(original_challenge)
363+
364+
expect(attestation_response.attestation_type).to eq("AnonCA")
365+
expect(attestation_response.attestation_trust_path).to all(be_kind_of(OpenSSL::X509::Certificate))
366+
end
367+
368+
it "returns the credential" do
369+
expect(attestation_response.credential.id.length).to be >= 16
370+
end
371+
end
372+
331373
it "returns user-friendly error if no client data received" do
332374
attestation_response = WebAuthn::AuthenticatorAttestationResponse.new(
333375
attestation_object: "",

0 commit comments

Comments
 (0)