ios_app_attest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +195 -0
- data/lib/ios_app_attest/configuration.rb +31 -0
- data/lib/ios_app_attest/errors.rb +110 -0
- data/lib/ios_app_attest/nonce_generator.rb +122 -0
- data/lib/ios_app_attest/validators/app_identity_validator.rb +104 -0
- data/lib/ios_app_attest/validators/attestation_validator.rb +57 -0
- data/lib/ios_app_attest/validators/base_validator.rb +52 -0
- data/lib/ios_app_attest/validators/certificate_validator.rb +142 -0
- data/lib/ios_app_attest/validators/challenge_validator.rb +143 -0
- data/lib/ios_app_attest/validators/utils.rb +39 -0
- data/lib/ios_app_attest/validators.rb +14 -0
- data/lib/ios_app_attest/verifier.rb +173 -0
- data/lib/ios_app_attest/version.rb +5 -0
- data/lib/ios_app_attest.rb +59 -0
- metadata +132 -0
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module IosAppAttest
|
4
|
+
module Validators
|
5
|
+
# Base class for all validators in the iOS App Attest validation process
|
6
|
+
#
|
7
|
+
# This class provides common functionality used by all validator classes,
|
8
|
+
# including logging, cryptographic utilities, and base64 encoding/decoding.
|
9
|
+
# All specific validators inherit from this class.
|
10
|
+
#
|
11
|
+
# @abstract Subclass and override validation methods to implement specific validation logic
|
12
|
+
class BaseValidator
|
13
|
+
attr_reader :config, :logger
|
14
|
+
|
15
|
+
# Initialize the validator
|
16
|
+
# @param config [IosAppAttest::Configuration] Configuration object
|
17
|
+
# @param logger [Object] Logger instance (optional)
|
18
|
+
def initialize(config, logger: nil)
|
19
|
+
@config = config
|
20
|
+
@logger = logger
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# Log error if logger is available
|
26
|
+
# @param message [String] The error message to log
|
27
|
+
def log_error(message)
|
28
|
+
logger&.error(message)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Get SHA256 digest
|
32
|
+
# @return [OpenSSL::Digest] SHA256 digest instance
|
33
|
+
def sha256
|
34
|
+
@sha256 ||= OpenSSL::Digest.new('SHA256')
|
35
|
+
end
|
36
|
+
|
37
|
+
# Decode base64 string
|
38
|
+
# @param base64_string [String] The base64 encoded string
|
39
|
+
# @return [String] The decoded string
|
40
|
+
def decode_base64(base64_string)
|
41
|
+
Base64.decode64(base64_string)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Encode base64 string
|
45
|
+
# @param string [String] The string to encode
|
46
|
+
# @return [String] The base64 encoded string
|
47
|
+
def encode_base64(string)
|
48
|
+
Base64.strict_encode64(string)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,142 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module IosAppAttest
|
4
|
+
module Validators
|
5
|
+
# Validates certificate chain and related aspects
|
6
|
+
#
|
7
|
+
# This validator is responsible for verifying the certificate chain,
|
8
|
+
# validating sequence structures, and ensuring the App Attest OID
|
9
|
+
# is present in the certificate.
|
10
|
+
#
|
11
|
+
# @example
|
12
|
+
# validator = IosAppAttest::Validators::CertificateValidator.new(config)
|
13
|
+
# cred_cert = validator.validate(attestation)
|
14
|
+
class CertificateValidator < BaseValidator
|
15
|
+
# Validate the certificate chain
|
16
|
+
#
|
17
|
+
# This method performs the following validations:
|
18
|
+
# 1. Extracts certificates from the attestation statement
|
19
|
+
# 2. Verifies the certificate chain against the Apple root CA
|
20
|
+
# 3. Validates that the certificate contains the App Attest OID
|
21
|
+
#
|
22
|
+
# @param attestation [Hash] The decoded attestation object containing x5c certificates
|
23
|
+
# @return [OpenSSL::X509::Certificate] The credential certificate if validation succeeds
|
24
|
+
# @raise [IosAppAttest::CertificateError] If certificate chain validation fails or App Attest OID is missing
|
25
|
+
def validate(attestation)
|
26
|
+
att_stmt = attestation['attStmt']
|
27
|
+
certificates = att_stmt['x5c'].map { |c| OpenSSL::X509::Certificate.new(c) }
|
28
|
+
cred_cert, *chain = certificates
|
29
|
+
|
30
|
+
context = OpenSSL::X509::StoreContext.new(certificates_store, cred_cert, chain)
|
31
|
+
unless context.verify
|
32
|
+
raise IosAppAttest::CertificateError,
|
33
|
+
"Certificate chain verification failed: #{context.error_string}"
|
34
|
+
end
|
35
|
+
|
36
|
+
verify_app_attest_oid(cred_cert)
|
37
|
+
cred_cert
|
38
|
+
end
|
39
|
+
|
40
|
+
# Validate the sequence structure in the certificate
|
41
|
+
#
|
42
|
+
# This method validates that the certificate extension with the App Attest OID
|
43
|
+
# contains a properly structured ASN.1 sequence. This is required for the
|
44
|
+
# challenge validation process.
|
45
|
+
#
|
46
|
+
# @param cred_cert [OpenSSL::X509::Certificate] The credential certificate to validate
|
47
|
+
# @raise [IosAppAttest::CertificateError] If sequence structure validation fails
|
48
|
+
def validate_sequence(cred_cert)
|
49
|
+
extension = cred_cert.extensions.find { |e| e.oid == app_attest_oid }
|
50
|
+
sequence = OpenSSL::ASN1.decode(OpenSSL::ASN1.decode(extension.to_der).value[1].value)
|
51
|
+
|
52
|
+
unless sequence.tag == OpenSSL::ASN1::SEQUENCE && sequence.value.size == 1
|
53
|
+
raise IosAppAttest::CertificateError, 'Failed sequence structure validation'
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Extract the public key from the certificate
|
58
|
+
#
|
59
|
+
# This method extracts the public key from the credential certificate
|
60
|
+
# in DER (Distinguished Encoding Rules) format for further validation.
|
61
|
+
#
|
62
|
+
# @param cred_cert [OpenSSL::X509::Certificate] The credential certificate
|
63
|
+
# @return [String] The public key in DER format
|
64
|
+
def extract_public_key(cred_cert)
|
65
|
+
cred_cert.public_key.to_der
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
# Validate the app attest OID in the certificate
|
71
|
+
#
|
72
|
+
# This method checks that the certificate contains the Apple App Attest OID
|
73
|
+
# extension, which is required for valid App Attestation certificates.
|
74
|
+
#
|
75
|
+
# @param certificate [OpenSSL::X509::Certificate] The certificate to validate
|
76
|
+
# @raise [IosAppAttest::CertificateError] If App Attest OID is missing
|
77
|
+
def verify_app_attest_oid(certificate)
|
78
|
+
has_oid = certificate.extensions.any? { |ext| ext.oid == app_attest_oid }
|
79
|
+
unless has_oid
|
80
|
+
raise IosAppAttest::CertificateError, "Missing App Attest OID in certificate"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Create certificates store with hardcoded root CA
|
85
|
+
#
|
86
|
+
# This method creates an OpenSSL certificate store and adds the hardcoded Apple
|
87
|
+
# root CA certificate to it for certificate chain validation.
|
88
|
+
#
|
89
|
+
# @return [OpenSSL::X509::Store] The certificate store with Apple root CA
|
90
|
+
def certificates_store
|
91
|
+
root_cert = OpenSSL::X509::Certificate.new(root_ca)
|
92
|
+
@certificates_store ||= OpenSSL::X509::Store.new.add_cert(root_cert)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Get hardcoded root CA
|
96
|
+
#
|
97
|
+
# This method returns the hardcoded Apple App Attestation root CA certificate content.
|
98
|
+
# The certificate is stored as a constant to avoid recreating the string on each call.
|
99
|
+
#
|
100
|
+
# @return [String] The Apple root CA certificate content
|
101
|
+
# @raise [IosAppAttest::CertificateError] If the certificate format is invalid
|
102
|
+
def root_ca
|
103
|
+
APPLE_APP_ATTEST_ROOT_CA
|
104
|
+
rescue StandardError => e
|
105
|
+
raise IosAppAttest::CertificateError, "Invalid root CA certificate format: #{e.message}"
|
106
|
+
end
|
107
|
+
|
108
|
+
# Apple App Attestation Root CA Certificate
|
109
|
+
# This is the official Apple App Attestation root CA certificate
|
110
|
+
APPLE_APP_ATTEST_ROOT_CA = <<~CERT
|
111
|
+
-----BEGIN CERTIFICATE-----
|
112
|
+
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
|
113
|
+
JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
|
114
|
+
QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
|
115
|
+
Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
|
116
|
+
biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
|
117
|
+
bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
|
118
|
+
NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
|
119
|
+
Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
|
120
|
+
MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
|
121
|
+
CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
|
122
|
+
53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
|
123
|
+
oyFraWVIyd/dganmrduC1bmTBGwD
|
124
|
+
-----END CERTIFICATE-----
|
125
|
+
CERT
|
126
|
+
|
127
|
+
# Apple App Attestation OID constant
|
128
|
+
# This OID identifies the App Attest extension in certificates
|
129
|
+
APP_ATTEST_OID = "1.2.840.113635.100.8.2"
|
130
|
+
|
131
|
+
# Get app attest OID
|
132
|
+
#
|
133
|
+
# This method returns the hardcoded Apple App Attest OID.
|
134
|
+
# The OID is used to identify the App Attest extension in certificates.
|
135
|
+
#
|
136
|
+
# @return [String] The Apple App Attest OID ("1.2.840.113635.100.8.2")
|
137
|
+
def app_attest_oid
|
138
|
+
APP_ATTEST_OID
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module IosAppAttest
|
4
|
+
module Validators
|
5
|
+
# Validates challenge nonce and related aspects of the attestation
|
6
|
+
#
|
7
|
+
# This validator is responsible for verifying the challenge nonce used in the
|
8
|
+
# attestation process. It validates that the nonce is valid, not expired, and
|
9
|
+
# matches the expected value. It also verifies the key ID against the certificate's
|
10
|
+
# public key and provides methods for decrypting challenges.
|
11
|
+
#
|
12
|
+
# @example
|
13
|
+
# validator = IosAppAttest::Validators::ChallengeValidator.new(config, redis_client: redis)
|
14
|
+
# validator.validate_nonce(challenge_id, challenge_decrypted)
|
15
|
+
# validator.validate_challenge(cred_cert, challenge_decrypted, auth_data)
|
16
|
+
# validator.validate_key_id(cred_cert, key_id)
|
17
|
+
class ChallengeValidator < BaseValidator
|
18
|
+
attr_reader :redis_client
|
19
|
+
|
20
|
+
# Initialize the challenge validator
|
21
|
+
#
|
22
|
+
# This initializes the validator with configuration and optional Redis client.
|
23
|
+
# The Redis client is used to store and retrieve nonces for verification.
|
24
|
+
# If no Redis client is provided, nonce verification will be skipped.
|
25
|
+
#
|
26
|
+
# @param config [IosAppAttest::Configuration] Configuration object with encryption keys and OIDs
|
27
|
+
# @param redis_client [Object] Redis client for nonce verification (optional)
|
28
|
+
# @param logger [Object] Logger instance for logging validation events (optional)
|
29
|
+
def initialize(config, redis_client: nil, logger: nil)
|
30
|
+
super(config, logger: logger)
|
31
|
+
@redis_client = redis_client
|
32
|
+
end
|
33
|
+
|
34
|
+
# Validate the challenge nonce against stored value
|
35
|
+
#
|
36
|
+
# This method verifies that the provided challenge nonce matches the one
|
37
|
+
# previously stored in Redis. After successful validation, the nonce is
|
38
|
+
# deleted from Redis to prevent replay attacks.
|
39
|
+
#
|
40
|
+
# @note This method requires a Redis client to be provided during initialization.
|
41
|
+
# If no Redis client is available, this validation is skipped.
|
42
|
+
#
|
43
|
+
# @param challenge_id [String] The challenge nonce ID used as Redis key
|
44
|
+
# @param challenge_decrypted [String] The decrypted challenge nonce to validate
|
45
|
+
# @raise [IosAppAttest::ChallengeError] If nonce is invalid, expired, or missing
|
46
|
+
def validate_nonce(challenge_id, challenge_decrypted)
|
47
|
+
return unless redis_client
|
48
|
+
|
49
|
+
nonce = redis_client.get("nonce:#{challenge_id}")
|
50
|
+
unless nonce && nonce == encode_base64(challenge_decrypted)
|
51
|
+
raise IosAppAttest::ChallengeError, "Invalid or expired challenge nonce"
|
52
|
+
end
|
53
|
+
|
54
|
+
# Delete the nonce after successful validation to prevent replay attacks
|
55
|
+
redis_client.del("nonce:#{challenge_id}")
|
56
|
+
end
|
57
|
+
|
58
|
+
# Verify challenge nonce from certificate
|
59
|
+
#
|
60
|
+
# This method verifies that the challenge nonce was correctly incorporated into
|
61
|
+
# the attestation by checking that the certificate contains a hash derived from
|
62
|
+
# the authentication data and challenge nonce. This ensures the attestation was
|
63
|
+
# created specifically for this challenge.
|
64
|
+
#
|
65
|
+
# @param cred_cert [OpenSSL::X509::Certificate] The credential certificate containing the App Attest extension
|
66
|
+
# @param challenge_decrypted [String] The decrypted challenge nonce to verify
|
67
|
+
# @param auth_data [String] The authentication data from the attestation
|
68
|
+
# @raise [IosAppAttest::ChallengeError] If challenge verification fails
|
69
|
+
def validate_challenge(cred_cert, challenge_decrypted, auth_data)
|
70
|
+
challenge_hash = sha256.digest(challenge_decrypted)
|
71
|
+
|
72
|
+
extension = cred_cert.extensions.find { |e| e.oid == app_attest_oid }
|
73
|
+
sequence = OpenSSL::ASN1.decode(OpenSSL::ASN1.decode(extension.to_der).value[1].value)
|
74
|
+
to_verify = sequence.value[0].value[0].value
|
75
|
+
|
76
|
+
expected_hash = sha256.digest(auth_data + challenge_hash)
|
77
|
+
unless to_verify == expected_hash
|
78
|
+
raise IosAppAttest::ChallengeError, 'Challenge verification failed'
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Validate the key ID matches the certificate's public key
|
83
|
+
#
|
84
|
+
# This method verifies that the key ID provided in the attestation parameters
|
85
|
+
# matches the hash of the public key from the credential certificate. This ensures
|
86
|
+
# the attestation is using the correct key pair.
|
87
|
+
#
|
88
|
+
# @param cred_cert [OpenSSL::X509::Certificate] The credential certificate containing the public key
|
89
|
+
# @param key_id [String] The key ID from attestation parameters to verify
|
90
|
+
# @raise [IosAppAttest::ChallengeError] If key ID verification fails
|
91
|
+
def validate_key_id(cred_cert, key_id)
|
92
|
+
uncompressed_point_key = cred_cert.public_key.public_key.to_octet_string(:uncompressed)
|
93
|
+
expected_key_id = Base64.strict_encode64(sha256.digest(uncompressed_point_key))
|
94
|
+
|
95
|
+
unless key_id == expected_key_id
|
96
|
+
raise IosAppAttest::ChallengeError, 'Key ID verification failed'
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Decrypt challenge using AES-256-CBC
|
101
|
+
#
|
102
|
+
# This method decrypts the challenge nonce using AES-256-CBC encryption with
|
103
|
+
# the provided initialization vector and the encryption key from configuration.
|
104
|
+
#
|
105
|
+
# @param challenge [String] The encrypted challenge nonce
|
106
|
+
# @param iv [String] The initialization vector used for encryption
|
107
|
+
# @return [String] The decrypted challenge nonce
|
108
|
+
def decrypt_challenge(challenge, iv)
|
109
|
+
cipher = OpenSSL::Cipher::AES256.new(:CBC)
|
110
|
+
cipher.decrypt
|
111
|
+
cipher.key = encryption_key
|
112
|
+
cipher.iv = iv
|
113
|
+
cipher.update(challenge) + cipher.final
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
# Apple App Attest OID constant
|
119
|
+
# This OID identifies the App Attest extension in certificates
|
120
|
+
APP_ATTEST_OID = "1.2.840.113635.100.8.2"
|
121
|
+
|
122
|
+
# Get app attest OID
|
123
|
+
#
|
124
|
+
# This method returns the hardcoded Apple App Attest OID.
|
125
|
+
# The OID is used to identify the App Attest extension in certificates.
|
126
|
+
#
|
127
|
+
# @return [String] The Apple App Attest OID ("1.2.840.113635.100.8.2")
|
128
|
+
def app_attest_oid
|
129
|
+
APP_ATTEST_OID
|
130
|
+
end
|
131
|
+
|
132
|
+
# Get encryption key from configuration
|
133
|
+
#
|
134
|
+
# This method retrieves the encryption key from the configuration object.
|
135
|
+
# The key is used for decrypting challenge nonces.
|
136
|
+
#
|
137
|
+
# @return [String] The encryption key used for AES-256-CBC decryption
|
138
|
+
def encryption_key
|
139
|
+
config.encryption_key
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module IosAppAttest
|
4
|
+
module Validators
|
5
|
+
# Utility methods for iOS App Attest validators
|
6
|
+
#
|
7
|
+
# This module provides common cryptographic and encoding utilities
|
8
|
+
# used throughout the iOS App Attest validation process.
|
9
|
+
# It includes methods for base64 encoding/decoding, SHA256 hashing,
|
10
|
+
# and AES cipher creation.
|
11
|
+
module Utils
|
12
|
+
# Decode base64 string
|
13
|
+
# @param base64_string [String] The base64 encoded string
|
14
|
+
# @return [String] The decoded string
|
15
|
+
def self.decode_base64(base64_string)
|
16
|
+
Base64.strict_decode64(base64_string)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Encode base64 string
|
20
|
+
# @param string [String] The string to encode
|
21
|
+
# @return [String] The base64 encoded string
|
22
|
+
def self.encode_base64(string)
|
23
|
+
Base64.strict_encode64(string)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Get SHA256 digest
|
27
|
+
# @return [OpenSSL::Digest] SHA256 digest instance
|
28
|
+
def self.sha256
|
29
|
+
OpenSSL::Digest.new('SHA256')
|
30
|
+
end
|
31
|
+
|
32
|
+
# Create AES cipher
|
33
|
+
# @return [OpenSSL::Cipher] AES256-CBC cipher instance
|
34
|
+
def self.cipher
|
35
|
+
OpenSSL::Cipher::AES256.new(:CBC)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'validators/utils'
|
4
|
+
require_relative 'validators/base_validator'
|
5
|
+
require_relative 'validators/attestation_validator'
|
6
|
+
require_relative 'validators/certificate_validator'
|
7
|
+
require_relative 'validators/challenge_validator'
|
8
|
+
require_relative 'validators/app_identity_validator'
|
9
|
+
|
10
|
+
module IosAppAttest
|
11
|
+
# Namespace for validators
|
12
|
+
module Validators
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'pry'
|
4
|
+
require_relative 'validators'
|
5
|
+
|
6
|
+
module IosAppAttest
|
7
|
+
# Verifies iOS App Attestation tokens
|
8
|
+
#
|
9
|
+
# The Verifier class is responsible for validating iOS App Attestation tokens
|
10
|
+
# received from iOS clients. It performs a series of validation steps to ensure
|
11
|
+
# the attestation is genuine and comes from a valid Apple device.
|
12
|
+
#
|
13
|
+
# @example Basic usage
|
14
|
+
# verifier = IosAppAttest::Verifier.new(attestation_params)
|
15
|
+
# public_key, receipt = verifier.verify
|
16
|
+
#
|
17
|
+
# @example With Redis for nonce validation
|
18
|
+
# verifier = IosAppAttest::Verifier.new(
|
19
|
+
# attestation_params,
|
20
|
+
# redis_client: redis
|
21
|
+
# )
|
22
|
+
# public_key, receipt = verifier.verify
|
23
|
+
class Verifier
|
24
|
+
|
25
|
+
attr_reader :attestation_params, :redis_client, :logger
|
26
|
+
|
27
|
+
# Initialize the verifier with attestation parameters
|
28
|
+
# @param attestation_params [Hash] The attestation parameters from the client
|
29
|
+
# @param redis_client [Object] Redis client for nonce verification (optional)
|
30
|
+
# @param logger [Object] Logger instance (optional)
|
31
|
+
def initialize(attestation_params, redis_client: nil, logger: nil)
|
32
|
+
@attestation_params = attestation_params
|
33
|
+
@redis_client = redis_client
|
34
|
+
@logger = logger
|
35
|
+
initialize_validators
|
36
|
+
end
|
37
|
+
|
38
|
+
# Verify the app attestation
|
39
|
+
#
|
40
|
+
# This method performs a complete verification of the iOS App Attestation token.
|
41
|
+
# It validates the attestation structure, certificate chain, challenge nonce,
|
42
|
+
# and app identity. If all validations pass, it returns the public key and receipt.
|
43
|
+
#
|
44
|
+
# @return [Array<String>] An array containing [public_key, receipt] if verification succeeds
|
45
|
+
# @raise [VerificationError] If verification fails for any reason
|
46
|
+
# @raise [NonceError] If nonce validation fails
|
47
|
+
# @raise [CertificateError] If certificate validation fails
|
48
|
+
# @raise [ChallengeError] If challenge validation fails
|
49
|
+
# @raise [AppIdentityError] If app identity validation fails
|
50
|
+
# @raise [AttestationError] If attestation format is invalid
|
51
|
+
def verify
|
52
|
+
begin
|
53
|
+
# Step 1: Decode the attestation object
|
54
|
+
attestation = decode_attestation
|
55
|
+
|
56
|
+
# Step 2: Validate the challenge nonce if Redis client is provided
|
57
|
+
if redis_client
|
58
|
+
challenge_validator.validate_nonce(challenge_id, challenge_decrypted)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Step 3: Validate the attestation structure and format
|
62
|
+
attestation_validator.validate(attestation)
|
63
|
+
|
64
|
+
# Step 4: Extract auth_data and receipt
|
65
|
+
auth_data = attestation_validator.extract_auth_data(attestation)
|
66
|
+
@receipt = attestation_validator.extract_receipt(attestation)
|
67
|
+
|
68
|
+
# Step 5: Validate the certificate chain and get the credential certificate
|
69
|
+
cred_cert = certificate_validator.validate(attestation)
|
70
|
+
|
71
|
+
# Step 6: Validate the challenge
|
72
|
+
challenge_validator.validate_challenge(cred_cert, challenge_decrypted, auth_data)
|
73
|
+
|
74
|
+
# Step 7: Validate the key ID
|
75
|
+
challenge_validator.validate_key_id(cred_cert, key_id)
|
76
|
+
|
77
|
+
# Step 8: Validate the certificate sequence structure
|
78
|
+
certificate_validator.validate_sequence(cred_cert)
|
79
|
+
|
80
|
+
# Step 9: Verify the app identity
|
81
|
+
app_identity_validator.validate(auth_data, key_id)
|
82
|
+
|
83
|
+
# Step 10: Extract the public key
|
84
|
+
@public_key = certificate_validator.extract_public_key(cred_cert)
|
85
|
+
rescue IosAppAttest::Error => error
|
86
|
+
# Re-raise IosAppAttest errors directly
|
87
|
+
log_error("IosAppAttest verification failed: #{error}")
|
88
|
+
raise error
|
89
|
+
rescue StandardError => error
|
90
|
+
# Wrap other errors in VerificationError
|
91
|
+
log_error("IosAppAttest verification failed: #{error}")
|
92
|
+
raise VerificationError, "Attestation verification failed: #{error.message}"
|
93
|
+
end
|
94
|
+
|
95
|
+
[public_key, receipt]
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
attr_reader :attestation_validator, :certificate_validator, :challenge_validator,
|
101
|
+
:app_identity_validator, :public_key, :receipt
|
102
|
+
|
103
|
+
# Initialize all validators
|
104
|
+
def initialize_validators
|
105
|
+
@attestation_validator = Validators::AttestationValidator.new(config, logger: logger)
|
106
|
+
@certificate_validator = Validators::CertificateValidator.new(config, logger: logger)
|
107
|
+
@challenge_validator = Validators::ChallengeValidator.new(
|
108
|
+
config,
|
109
|
+
redis_client: redis_client,
|
110
|
+
logger: logger
|
111
|
+
)
|
112
|
+
@app_identity_validator = Validators::AppIdentityValidator.new(config, logger: logger)
|
113
|
+
end
|
114
|
+
|
115
|
+
# Get IosAppAttest configuration
|
116
|
+
# @return [IosAppAttest::Configuration] The configuration object
|
117
|
+
def config
|
118
|
+
IosAppAttest.configuration
|
119
|
+
end
|
120
|
+
|
121
|
+
# Decrypt challenge using AES
|
122
|
+
# @return [String] The decrypted challenge
|
123
|
+
def challenge_decrypted
|
124
|
+
challenge_validator.decrypt_challenge(challenge, iv)
|
125
|
+
end
|
126
|
+
|
127
|
+
#---------------------------
|
128
|
+
# Parameter Accessors
|
129
|
+
#---------------------------
|
130
|
+
|
131
|
+
# Get key ID from attestation parameters
|
132
|
+
# @return [String] The key ID
|
133
|
+
def key_id
|
134
|
+
@key_id ||= attestation_params[:key_id] || attestation_params["key_id"]
|
135
|
+
end
|
136
|
+
|
137
|
+
# Get attestation object from attestation parameters
|
138
|
+
# @return [String] The decoded attestation object
|
139
|
+
def attestation_object
|
140
|
+
@attestation_object ||= Validators::Utils.decode_base64(attestation_params[:attestation_object] || attestation_params["attestation_object"])
|
141
|
+
end
|
142
|
+
|
143
|
+
# Get challenge ID from attestation parameters
|
144
|
+
# @return [String] The challenge nonce ID
|
145
|
+
def challenge_id
|
146
|
+
@challenge_id ||= attestation_params[:challenge_nonce_id] || attestation_params["challenge_nonce_id"]
|
147
|
+
end
|
148
|
+
|
149
|
+
# Get challenge from attestation parameters
|
150
|
+
# @return [String] The decoded challenge nonce
|
151
|
+
def challenge
|
152
|
+
@challenge ||= Validators::Utils.decode_base64(attestation_params[:challenge_nonce] || attestation_params["challenge_nonce"])
|
153
|
+
end
|
154
|
+
|
155
|
+
# Get IV from attestation parameters
|
156
|
+
# @return [String] The decoded initialization vector
|
157
|
+
def iv
|
158
|
+
@iv ||= Validators::Utils.decode_base64(attestation_params[:initialization_vector] || attestation_params["initialization_vector"])
|
159
|
+
end
|
160
|
+
|
161
|
+
# Log error if logger is available
|
162
|
+
# @param message [String] The error message to log
|
163
|
+
def log_error(message)
|
164
|
+
logger&.error(message)
|
165
|
+
end
|
166
|
+
|
167
|
+
# Decode the attestation object from base64
|
168
|
+
# @return [Hash] The decoded attestation object
|
169
|
+
def decode_attestation
|
170
|
+
CBOR.decode(attestation_object)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "cbor"
|
4
|
+
require "openssl"
|
5
|
+
require "base64"
|
6
|
+
require "securerandom"
|
7
|
+
require_relative "ios_app_attest/version"
|
8
|
+
require_relative "ios_app_attest/configuration"
|
9
|
+
require_relative "ios_app_attest/errors"
|
10
|
+
require_relative "ios_app_attest/validators"
|
11
|
+
require_relative "ios_app_attest/verifier"
|
12
|
+
require_relative "ios_app_attest/nonce_generator"
|
13
|
+
|
14
|
+
# Main module for iOS App Attest verification
|
15
|
+
#
|
16
|
+
# This module provides functionality for verifying iOS App Attest attestations.
|
17
|
+
# It includes classes for configuration, verification, nonce generation, and
|
18
|
+
# various validators for different aspects of the attestation process.
|
19
|
+
#
|
20
|
+
# @example Basic usage
|
21
|
+
# IosAppAttest.configure do |config|
|
22
|
+
# config.app_id = "TEAM123.com.example.app"
|
23
|
+
# config.encryption_key = SecureRandom.random_bytes(32)
|
24
|
+
# end
|
25
|
+
# # Note: App Attest OID ("1.2.840.113635.100.8.2") is hardcoded in the gem
|
26
|
+
#
|
27
|
+
# verifier = IosAppAttest::Verifier.new
|
28
|
+
# verifier.verify(attestation_object, challenge_id, key_id)
|
29
|
+
#
|
30
|
+
module IosAppAttest
|
31
|
+
# Configuration options for the IosAppAttest module
|
32
|
+
class << self
|
33
|
+
attr_accessor :configuration
|
34
|
+
|
35
|
+
# Configure the IosAppAttest module
|
36
|
+
#
|
37
|
+
# This method allows configuration of the IosAppAttest module using a block.
|
38
|
+
# It yields the configuration object to the block, allowing for setting
|
39
|
+
# various configuration parameters.
|
40
|
+
#
|
41
|
+
# @example
|
42
|
+
# IosAppAttest.configure do |config|
|
43
|
+
# config.app_id = "TEAM123.com.example.app"
|
44
|
+
# config.encryption_key = SecureRandom.random_bytes(32)
|
45
|
+
# end
|
46
|
+
# # Note: App Attest OID is hardcoded in the gem
|
47
|
+
#
|
48
|
+
# @yield [configuration] The configuration object to be modified
|
49
|
+
# @return [Configuration] The current configuration object
|
50
|
+
def configure
|
51
|
+
self.configuration ||= Configuration.new
|
52
|
+
yield(configuration) if block_given?
|
53
|
+
configuration
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Initialize with default configuration
|
58
|
+
configure
|
59
|
+
end
|