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
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 8e8e4a0dac87b3cff72d1f82ab119a3a631226aa5fd6debfe3698e13152fb551
|
4
|
+
data.tar.gz: 88d54eb7e43e0c6ce051fba922d18cdd43e0fc83811247020aef149241875492
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 98b702ff259f7987941d32830f581e9e300cf6685f8519dca6ce67757cddfe8960e5dd535b6886435690568c90de4ed3264a8a98f3080970370c024909c01915
|
7
|
+
data.tar.gz: '009d9bbfcf4743ee3eb8bb4f12ce490f30e5ce367061231588ffaa276bd12be113a386a3c3634f56d282592bb127dc5287d3291447591cc8c8c2a959775b6fd2'
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
|
+
|
8
|
+
## [0.1.0] - 2025-08-11
|
9
|
+
|
10
|
+
### Added
|
11
|
+
- Initial release of the ios_app_attest gem
|
12
|
+
- Core functionality for verifying iOS App Attestation tokens
|
13
|
+
- Support for nonce validation with Redis
|
14
|
+
- Comprehensive error handling with specific error subclasses
|
15
|
+
- Configuration options for app ID and encryption key
|
16
|
+
- Extensive YARD documentation for all public methods and classes
|
17
|
+
- Detailed examples in the examples/ directory
|
18
|
+
- Security policy in SECURITY.md
|
19
|
+
- Comprehensive test suite with 83 passing tests
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Chaitra Mudili
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,195 @@
|
|
1
|
+
# iOS App Attest
|
2
|
+
|
3
|
+
A Ruby gem for verifying iOS App Attest tokens - Apple's device attestation mechanism for iOS apps.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'ios_app_attest'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
```bash
|
16
|
+
$ bundle install
|
17
|
+
```
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
```bash
|
22
|
+
$ gem install ios_app_attest
|
23
|
+
```
|
24
|
+
|
25
|
+
## Usage
|
26
|
+
|
27
|
+
### Configuration
|
28
|
+
|
29
|
+
Configure the gem with your app's specific settings:
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
IosAppAttest.configure do |config|
|
33
|
+
config.app_id = "TEAM_ID.BUNDLE_ID" # Your Apple Team ID and Bundle ID
|
34
|
+
config.encryption_key = ENV.fetch("IOS_APP_ATTEST_TOKEN").byteslice(0, 32) # Your encryption key (32 bytes)
|
35
|
+
end
|
36
|
+
```
|
37
|
+
|
38
|
+
> Note: The Apple App Attestation root CA certificate and App Attest OID ("1.2.840.113635.100.8.2") are now hardcoded in the gem for security and convenience.
|
39
|
+
|
40
|
+
### Complete Attestation Flow
|
41
|
+
|
42
|
+
#### 1. Generating and Storing Challenge Nonces
|
43
|
+
|
44
|
+
When a client requests a nonce, your server generates it and stores it in Redis with a TTL:
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
# Create a Redis client for nonce storage
|
48
|
+
redis = Redis.new(url: ENV["REDIS_URL"] || "redis://localhost:6379/0")
|
49
|
+
|
50
|
+
# Create a nonce generator
|
51
|
+
nonce_generator = IosAppAttest::NonceGenerator.new(
|
52
|
+
redis_client: redis,
|
53
|
+
logger: Rails.logger, # Optional
|
54
|
+
expiry_seconds: 300 # Optional: Nonce expiry time in seconds (default: 120)
|
55
|
+
)
|
56
|
+
|
57
|
+
# Generate a nonce (this also stores it in Redis with the configured TTL)
|
58
|
+
nonce_data = nonce_generator.generate
|
59
|
+
|
60
|
+
# The nonce_data contains:
|
61
|
+
# - challenge_nonce_id: A unique identifier for the challenge (used as Redis key)
|
62
|
+
# - challenge_nonce: The encrypted challenge nonce (base64 encoded)
|
63
|
+
# - initialization_vector: The IV used for encryption (base64 encoded)
|
64
|
+
|
65
|
+
# Send this data to the client for attestation
|
66
|
+
```
|
67
|
+
|
68
|
+
#### 2. Client-Side Processing
|
69
|
+
|
70
|
+
```
|
71
|
+
# On the client side (iOS app):
|
72
|
+
# 1. Receive the nonce data from the server
|
73
|
+
# 2. Decrypt the challenge_nonce using the same encryption key:
|
74
|
+
# a. Base64 decode the challenge_nonce and initialization_vector
|
75
|
+
# b. Use AES-256-CBC with the shared encryption key to decrypt the challenge
|
76
|
+
# 3. Use the decrypted nonce in the App Attestation process
|
77
|
+
# 4. Send the attestation object back to the server along with the original nonce data
|
78
|
+
```
|
79
|
+
|
80
|
+
#### 3. Server-Side Verification
|
81
|
+
|
82
|
+
When the client sends back the attestation object along with the original nonce data, the server:
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
# Attestation parameters from the client
|
86
|
+
attestation_params = {
|
87
|
+
attestation_object: "base64_encoded_attestation_object",
|
88
|
+
key_id: "base64_encoded_key_id",
|
89
|
+
challenge_nonce: "base64_encoded_challenge", # The encrypted challenge nonce (base64 encoded)
|
90
|
+
initialization_vector: "base64_encoded_initialization_vector", # The IV used for encryption (base64 encoded)
|
91
|
+
challenge_nonce_id: "challenge_id" # The unique identifier for the challenge
|
92
|
+
}
|
93
|
+
|
94
|
+
# Create a verifier with Redis client for nonce verification
|
95
|
+
verifier = IosAppAttest::Verifier.new(
|
96
|
+
attestation_params,
|
97
|
+
redis_client: redis, # Redis client for nonce verification (required for TTL check)
|
98
|
+
logger: Rails.logger # Optional: Logger for error logging
|
99
|
+
)
|
100
|
+
|
101
|
+
begin
|
102
|
+
# Verify the attestation - this process includes:
|
103
|
+
# 1. Decrypting the challenge nonce using the encryption key
|
104
|
+
# 2. Checking if the nonce exists in Redis (validates TTL)
|
105
|
+
# 3. Validating the attestation structure and certificates
|
106
|
+
# 4. Verifying the nonce matches what was used in the attestation
|
107
|
+
public_key, receipt = verifier.verify
|
108
|
+
|
109
|
+
# Use the public_key and receipt for further processing
|
110
|
+
# e.g., store the public_key for future authentications
|
111
|
+
rescue IosAppAttest::CertificateError => e
|
112
|
+
# Handle certificate validation errors
|
113
|
+
puts "Certificate validation failed: #{e.message}"
|
114
|
+
rescue IosAppAttest::ChallengeError => e
|
115
|
+
# Handle challenge validation errors
|
116
|
+
puts "Challenge validation failed: #{e.message}"
|
117
|
+
rescue IosAppAttest::AttestationError => e
|
118
|
+
# Handle attestation format errors
|
119
|
+
puts "Attestation format invalid: #{e.message}"
|
120
|
+
rescue IosAppAttest::AppIdentityError => e
|
121
|
+
# Handle app identity validation errors
|
122
|
+
puts "App identity validation failed: #{e.message}"
|
123
|
+
rescue IosAppAttest::NonceError => e
|
124
|
+
# Handle nonce validation errors
|
125
|
+
puts "Nonce validation failed: #{e.message}"
|
126
|
+
rescue IosAppAttest::VerificationError => e
|
127
|
+
# Handle other verification errors
|
128
|
+
puts "Verification failed: #{e.message}"
|
129
|
+
end
|
130
|
+
```
|
131
|
+
|
132
|
+
### Complete Flow
|
133
|
+
|
134
|
+
Here's the complete flow for implementing iOS App Attestation in your application:
|
135
|
+
|
136
|
+
1. **Server-side**: Generate a challenge nonce
|
137
|
+
```ruby
|
138
|
+
nonce_data = nonce_generator.generate
|
139
|
+
# Returns: { challenge_nonce_id:, challenge_nonce:, initialization_vector: }
|
140
|
+
```
|
141
|
+
|
142
|
+
2. **Send to Client**: Send the nonce data to your iOS client
|
143
|
+
|
144
|
+
3. **Client-side**: The iOS client uses the nonce to generate an attestation using Apple's DeviceCheck framework
|
145
|
+
```swift
|
146
|
+
// Swift code (client-side)
|
147
|
+
let service = DCAppAttestService.shared
|
148
|
+
if service.isSupported {
|
149
|
+
// Generate a new key pair
|
150
|
+
service.generateKey { keyId, error in
|
151
|
+
// Use the keyId and challenge to generate an attestation
|
152
|
+
service.attestKey(keyId, clientDataHash: challengeHash) { attestation, error in
|
153
|
+
// Send attestation, keyId, and challenge data back to server
|
154
|
+
}
|
155
|
+
}
|
156
|
+
}
|
157
|
+
```
|
158
|
+
|
159
|
+
4. **Server-side**: Verify the attestation
|
160
|
+
```ruby
|
161
|
+
verifier = IosAppAttest::Verifier.new(
|
162
|
+
attestation_params,
|
163
|
+
redis_client: redis
|
164
|
+
)
|
165
|
+
|
166
|
+
public_key, receipt = verifier.verify
|
167
|
+
# Store the public_key for future authentications
|
168
|
+
```
|
169
|
+
|
170
|
+
5. **Future Assertions**: For subsequent requests, the client generates assertions that can be verified using the stored public key
|
171
|
+
|
172
|
+
### Parameter Reference
|
173
|
+
|
174
|
+
The library uses the following parameter names:
|
175
|
+
|
176
|
+
| Parameter Name | Description |
|
177
|
+
|----------------|-------------|
|
178
|
+
| `challenge_nonce_id` | Unique identifier for the challenge nonce |
|
179
|
+
| `challenge_nonce` | Base64-encoded encrypted challenge nonce |
|
180
|
+
| `initialization_vector` | Base64-encoded initialization vector used for encryption |
|
181
|
+
| `attestation_object` | Base64-encoded attestation object from Apple's DeviceCheck framework |
|
182
|
+
| `key_id` | Base64-encoded key ID generated by the client |
|
183
|
+
|
184
|
+
|
185
|
+
## Development
|
186
|
+
|
187
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
188
|
+
|
189
|
+
## Contributing
|
190
|
+
|
191
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/chaitra-mudili/ios-app-attest-ruby.
|
192
|
+
|
193
|
+
## License
|
194
|
+
|
195
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module IosAppAttest
|
4
|
+
# Configuration class for IosAppAttest
|
5
|
+
#
|
6
|
+
# This class holds all configuration parameters required for the iOS App Attest
|
7
|
+
# verification process. It includes the app identifier and encryption key for challenge nonce encryption/decryption.
|
8
|
+
#
|
9
|
+
# @example
|
10
|
+
# IosAppAttest.configure do |config|
|
11
|
+
# config.app_id = "TEAM_ID.BUNDLE_ID"
|
12
|
+
# config.encryption_key = ENV["IOS_APP_ATTEST_TOKEN"].byteslice(0, 32)
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# @attr [String] app_id The Apple Team ID and Bundle ID in the format "TEAM_ID.BUNDLE_ID"
|
16
|
+
# @attr [String] encryption_key The encryption key used for challenge nonce encryption (32 bytes)
|
17
|
+
class Configuration
|
18
|
+
attr_accessor :app_id, :encryption_key
|
19
|
+
|
20
|
+
# Initialize a new Configuration instance with default values
|
21
|
+
#
|
22
|
+
# Creates a new configuration object with all attributes set to nil.
|
23
|
+
# These attributes must be set before using the configuration with validators.
|
24
|
+
#
|
25
|
+
# @return [IosAppAttest::Configuration] A new Configuration instance with nil values
|
26
|
+
def initialize
|
27
|
+
@app_id = nil
|
28
|
+
@encryption_key = nil
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Error classes for iOS App Attest verification
|
4
|
+
#
|
5
|
+
# This file defines a hierarchy of error classes for the iOS App Attest verification process.
|
6
|
+
# The hierarchy is designed to allow for specific error handling based on the type of error
|
7
|
+
# that occurred during verification.
|
8
|
+
#
|
9
|
+
# The main error categories are:
|
10
|
+
# - ConfigurationError: For configuration-related errors
|
11
|
+
# - VerificationError: Base class for all verification errors
|
12
|
+
# - NonceError: For nonce-related errors
|
13
|
+
# - CertificateError: For certificate-related errors
|
14
|
+
# - ChallengeError: For challenge-related errors
|
15
|
+
# - AppIdentityError: For app identity-related errors
|
16
|
+
# - AttestationError: For attestation format-related errors
|
17
|
+
#
|
18
|
+
# Each category has specific subclasses for more granular error handling.
|
19
|
+
#
|
20
|
+
module IosAppAttest
|
21
|
+
# Base error class for all IosAppAttest errors
|
22
|
+
class Error < StandardError; end
|
23
|
+
|
24
|
+
# Error raised when configuration is invalid or incomplete
|
25
|
+
#
|
26
|
+
# This error is raised when the configuration object is missing required
|
27
|
+
# parameters or contains invalid values.
|
28
|
+
class ConfigurationError < Error; end
|
29
|
+
|
30
|
+
# Error raised when attestation verification fails
|
31
|
+
#
|
32
|
+
# This is the base class for all verification-related errors.
|
33
|
+
# More specific error subclasses should be used when possible.
|
34
|
+
class VerificationError < Error; end
|
35
|
+
|
36
|
+
# Error raised when nonce validation fails
|
37
|
+
#
|
38
|
+
# This error is raised when there are issues with the challenge nonce
|
39
|
+
# during the verification process, such as expired, already used,
|
40
|
+
# or not found nonces.
|
41
|
+
class NonceError < VerificationError
|
42
|
+
# Error raised when a nonce has expired
|
43
|
+
class Expired < NonceError; end
|
44
|
+
|
45
|
+
# Error raised when a nonce has already been used
|
46
|
+
class AlreadyUsed < NonceError; end
|
47
|
+
|
48
|
+
# Error raised when a nonce is not found
|
49
|
+
class NotFound < NonceError; end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Error raised when certificate validation fails
|
53
|
+
#
|
54
|
+
# This error is raised when there are issues with the certificate
|
55
|
+
# during the verification process, such as invalid chain, expired certificate,
|
56
|
+
# or invalid certificate structure.
|
57
|
+
class CertificateError < VerificationError
|
58
|
+
# Error raised when certificate chain validation fails
|
59
|
+
class ChainInvalid < CertificateError; end
|
60
|
+
|
61
|
+
# Error raised when certificate has expired
|
62
|
+
class Expired < CertificateError; end
|
63
|
+
|
64
|
+
# Error raised when certificate is not yet valid
|
65
|
+
class NotYetValid < CertificateError; end
|
66
|
+
|
67
|
+
# Error raised when certificate structure is invalid
|
68
|
+
class InvalidStructure < CertificateError; end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Error raised when challenge validation fails
|
72
|
+
#
|
73
|
+
# This error is raised when there are issues with the challenge
|
74
|
+
# during the verification process, such as invalid signature,
|
75
|
+
# invalid format, or key ID mismatch.
|
76
|
+
class ChallengeError < VerificationError
|
77
|
+
# Error raised when challenge signature is invalid
|
78
|
+
class InvalidSignature < ChallengeError; end
|
79
|
+
|
80
|
+
# Error raised when challenge format is invalid
|
81
|
+
class InvalidFormat < ChallengeError; end
|
82
|
+
|
83
|
+
# Error raised when key ID doesn't match
|
84
|
+
class KeyIdMismatch < ChallengeError; end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Error raised when app identity validation fails
|
88
|
+
#
|
89
|
+
# This error is raised when there are issues with the app identity
|
90
|
+
# during the verification process, such as ID mismatch or invalid format.
|
91
|
+
class AppIdentityError < VerificationError
|
92
|
+
# Error raised when app ID doesn't match
|
93
|
+
class IdMismatch < AppIdentityError; end
|
94
|
+
|
95
|
+
# Error raised when app identity format is invalid
|
96
|
+
class InvalidFormat < AppIdentityError; end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Error raised when attestation format is invalid
|
100
|
+
#
|
101
|
+
# This error is raised when there are issues with the attestation format
|
102
|
+
# during the verification process, such as invalid structure or missing data.
|
103
|
+
class AttestationError < VerificationError
|
104
|
+
# Error raised when attestation structure is invalid
|
105
|
+
class InvalidStructure < AttestationError; end
|
106
|
+
|
107
|
+
# Error raised when attestation data is missing
|
108
|
+
class MissingData < AttestationError; end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
require "base64"
|
5
|
+
require "securerandom"
|
6
|
+
|
7
|
+
module IosAppAttest
|
8
|
+
# Generates and manages challenge nonces for iOS App Attestation
|
9
|
+
#
|
10
|
+
# The NonceGenerator class is responsible for creating secure random nonces,
|
11
|
+
# encrypting them, and storing them in Redis for later validation during
|
12
|
+
# the attestation verification process.
|
13
|
+
#
|
14
|
+
# This class uses IosAppAttest::NonceError for error handling.
|
15
|
+
#
|
16
|
+
# @example Basic usage
|
17
|
+
# redis = Redis.new
|
18
|
+
# generator = IosAppAttest::NonceGenerator.new(redis_client: redis)
|
19
|
+
# nonce_data = generator.generate
|
20
|
+
#
|
21
|
+
# @example With custom expiry time
|
22
|
+
# generator = IosAppAttest::NonceGenerator.new(
|
23
|
+
# redis_client: redis,
|
24
|
+
# expiry_seconds: 300
|
25
|
+
# )
|
26
|
+
# nonce_data = generator.generate
|
27
|
+
class NonceGenerator
|
28
|
+
|
29
|
+
attr_reader :redis_client, :logger, :expiry_seconds
|
30
|
+
|
31
|
+
# Initialize the nonce generator
|
32
|
+
# @param redis_client [Object] Redis client for nonce storage
|
33
|
+
# @param logger [Object] Logger instance (optional)
|
34
|
+
# @param expiry_seconds [Integer] Nonce expiry time in seconds (default: 120)
|
35
|
+
def initialize(redis_client:, logger: nil, expiry_seconds: 120)
|
36
|
+
@redis_client = redis_client
|
37
|
+
@logger = logger
|
38
|
+
@expiry_seconds = expiry_seconds
|
39
|
+
end
|
40
|
+
|
41
|
+
# Generate a new nonce and store it in Redis
|
42
|
+
#
|
43
|
+
# This method generates a cryptographically secure random nonce,
|
44
|
+
# encrypts it using AES-256-CBC, and stores it in Redis for later validation.
|
45
|
+
# The nonce is stored with an expiry time specified during initialization.
|
46
|
+
#
|
47
|
+
# @return [Hash] Hash containing:
|
48
|
+
# - :challenge_nonce_id [String] A unique identifier for the challenge
|
49
|
+
# - :challenge_nonce [String] Base64-encoded encrypted challenge nonce
|
50
|
+
# - :initialization_vector [String] Base64-encoded initialization vector
|
51
|
+
# @raise [IosAppAttest::NonceError] If nonce generation fails due to Redis errors or configuration issues
|
52
|
+
def generate
|
53
|
+
begin
|
54
|
+
store_nonce_in_redis
|
55
|
+
encrypted_nonce, iv = encrypt
|
56
|
+
rescue IosAppAttest::Error => error
|
57
|
+
# Re-raise IosAppAttest errors directly
|
58
|
+
log_error("IosAppAttest nonce generation failed: #{error}")
|
59
|
+
raise error
|
60
|
+
rescue StandardError => error
|
61
|
+
# Wrap other errors in NonceGenerationError
|
62
|
+
log_error("IosAppAttest nonce generation failed: #{error}")
|
63
|
+
raise IosAppAttest::NonceError, "Nonce generation failed: #{error.message}"
|
64
|
+
end
|
65
|
+
|
66
|
+
{
|
67
|
+
challenge_nonce_id: nonce_id,
|
68
|
+
challenge_nonce: base64_encode(encrypted_nonce),
|
69
|
+
initialization_vector: base64_encode(iv)
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
# Encrypt the raw nonce using AES-256-CBC
|
76
|
+
def encrypt
|
77
|
+
cipher.encrypt
|
78
|
+
iv = cipher.random_iv
|
79
|
+
cipher.key = encryption_key
|
80
|
+
cipher.iv = iv
|
81
|
+
encrypted = cipher.update(raw_nonce) + cipher.final
|
82
|
+
[encrypted, iv]
|
83
|
+
end
|
84
|
+
|
85
|
+
# Get AES cipher
|
86
|
+
def cipher
|
87
|
+
@cipher ||= OpenSSL::Cipher::AES256.new(:CBC)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Generate a random nonce
|
91
|
+
def raw_nonce
|
92
|
+
@raw_nonce ||= SecureRandom.random_bytes(32)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Generate a unique nonce ID
|
96
|
+
def nonce_id
|
97
|
+
@nonce_id ||= SecureRandom.uuid
|
98
|
+
end
|
99
|
+
|
100
|
+
# Store the nonce in Redis with expiry
|
101
|
+
def store_nonce_in_redis
|
102
|
+
redis_client.set("nonce:#{nonce_id}", base64_encode(raw_nonce), ex: expiry_seconds)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Get encryption key from configuration
|
106
|
+
def encryption_key
|
107
|
+
key = IosAppAttest.configuration.encryption_key
|
108
|
+
raise IosAppAttest::NonceError, "Encryption key not configured" unless key
|
109
|
+
key
|
110
|
+
end
|
111
|
+
|
112
|
+
# Encode base64 string
|
113
|
+
def base64_encode(base64_string)
|
114
|
+
Base64.strict_encode64(base64_string)
|
115
|
+
end
|
116
|
+
|
117
|
+
# Log error if logger is available
|
118
|
+
def log_error(message)
|
119
|
+
logger&.error(message)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module IosAppAttest
|
4
|
+
module Validators
|
5
|
+
# Validates app identity using authentication data
|
6
|
+
#
|
7
|
+
# This validator is responsible for verifying the application identity
|
8
|
+
# by checking the authentication data from the attestation object.
|
9
|
+
# It validates the relying party ID hash, sign count, AAGUID, and credential ID.
|
10
|
+
#
|
11
|
+
# @example
|
12
|
+
# validator = IosAppAttest::Validators::AppIdentityValidator.new(config)
|
13
|
+
# validator.validate(auth_data, key_id)
|
14
|
+
class AppIdentityValidator < BaseValidator
|
15
|
+
# Verify app identity using authentication data
|
16
|
+
#
|
17
|
+
# This method performs the following validations:
|
18
|
+
# 1. Unpacks the authentication data to extract required components
|
19
|
+
# 2. Verifies the relying party ID hash matches the configured app ID
|
20
|
+
# 3. Ensures the sign count is zero (required for initial attestation)
|
21
|
+
# 4. Validates the AAGUID matches the expected Apple App Attest value
|
22
|
+
# 5. Verifies the credential ID matches the provided key ID
|
23
|
+
#
|
24
|
+
# @param auth_data [String] The authentication data from the attestation object
|
25
|
+
# @param key_id [String] The key ID from attestation parameters
|
26
|
+
# @raise [IosAppAttest::AppIdentityError] If any app identity verification check fails
|
27
|
+
def validate(auth_data, key_id)
|
28
|
+
rp_id_hash, sign_count, aaguid, credential_id = unpack_auth_data(auth_data)
|
29
|
+
|
30
|
+
# Verify relying party ID hash
|
31
|
+
unless rp_id_hash == sha256.digest(app_id)
|
32
|
+
raise IosAppAttest::AppIdentityError, 'App ID verification failed'
|
33
|
+
end
|
34
|
+
|
35
|
+
# Verify sign count is zero (first attestation)
|
36
|
+
unless sign_count.zero?
|
37
|
+
raise IosAppAttest::AppIdentityError, 'Sign counter must be zero for initial attestation'
|
38
|
+
end
|
39
|
+
|
40
|
+
# Verify AAGUID
|
41
|
+
unless validate_aaguid(aaguid)
|
42
|
+
raise IosAppAttest::AppIdentityError, 'Invalid AAGUID for App Attestation'
|
43
|
+
end
|
44
|
+
|
45
|
+
# Verify credential ID matches key ID
|
46
|
+
unless key_id == Base64.strict_encode64(credential_id)
|
47
|
+
raise IosAppAttest::AppIdentityError, 'Credential ID does not match key ID'
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
# Unpack required objects from authentication data
|
54
|
+
#
|
55
|
+
# This method parses the binary authentication data according to the
|
56
|
+
# WebAuthn/FIDO2 specification format to extract the components needed
|
57
|
+
# for app identity validation.
|
58
|
+
#
|
59
|
+
# @param auth_data [String] The authentication data from the attestation object
|
60
|
+
# @return [Array] Array containing [rp_id_hash, sign_count, aaguid, credential_id]
|
61
|
+
def unpack_auth_data(auth_data)
|
62
|
+
(rp_id_hash, flags, sign_count, trailing_bytes) =
|
63
|
+
auth_data.unpack('a32c1N1a*')
|
64
|
+
|
65
|
+
(aaguid, credential_id_length, trailing_bytes) =
|
66
|
+
trailing_bytes.unpack('a16na*')
|
67
|
+
|
68
|
+
(credential_id, credential_public_key) =
|
69
|
+
trailing_bytes.unpack("a#{credential_id_length}a*")
|
70
|
+
|
71
|
+
[rp_id_hash, sign_count, aaguid, credential_id]
|
72
|
+
end
|
73
|
+
|
74
|
+
# Validate AAGUID (Authenticator Attestation Globally Unique Identifier)
|
75
|
+
#
|
76
|
+
# This method checks if the AAGUID matches the expected value for
|
77
|
+
# Apple App Attestation. In non-production environments, it also
|
78
|
+
# accepts the development AAGUID value.
|
79
|
+
#
|
80
|
+
# @param aaguid [String] The AAGUID extracted from authentication data
|
81
|
+
# @return [Boolean] True if AAGUID is valid for App Attestation
|
82
|
+
def validate_aaguid(aaguid)
|
83
|
+
expected_aaguid = "appattest\x00\x00\x00\x00\x00\x00\x00"
|
84
|
+
|
85
|
+
# Allow development AAGUID in non-production environments
|
86
|
+
if ENV['IOS_APP_ATTEST_ENV'] != 'production'
|
87
|
+
aaguid == 'appattestdevelop' || aaguid == expected_aaguid
|
88
|
+
else
|
89
|
+
aaguid == expected_aaguid
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Get app ID from configuration
|
94
|
+
#
|
95
|
+
# This method retrieves the application identifier from the configuration.
|
96
|
+
# The app ID is used to verify the relying party ID hash in the authentication data.
|
97
|
+
#
|
98
|
+
# @return [String] The configured application identifier
|
99
|
+
def app_id
|
100
|
+
config.app_id
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module IosAppAttest
|
4
|
+
module Validators
|
5
|
+
# Validates attestation structure and format
|
6
|
+
#
|
7
|
+
# This validator is responsible for verifying the structure and format
|
8
|
+
# of the attestation object received from the Apple App Attest service.
|
9
|
+
# It ensures the attestation contains all required keys and has the correct format.
|
10
|
+
#
|
11
|
+
# @example
|
12
|
+
# validator = IosAppAttest::Validators::AttestationValidator.new(config)
|
13
|
+
# validator.validate(attestation)
|
14
|
+
class AttestationValidator < BaseValidator
|
15
|
+
# Validate the attestation object structure
|
16
|
+
# @param attestation [Hash] The decoded attestation object
|
17
|
+
# @raise [IosAppAttest::AttestationError] If attestation structure is invalid
|
18
|
+
def validate(attestation)
|
19
|
+
required_keys = %w[fmt attStmt authData]
|
20
|
+
missing_keys = required_keys - attestation.keys.map(&:to_s)
|
21
|
+
|
22
|
+
if missing_keys.any?
|
23
|
+
raise IosAppAttest::AttestationError,
|
24
|
+
"Missing required attestation keys: #{missing_keys.join(', ')}"
|
25
|
+
end
|
26
|
+
|
27
|
+
unless attestation['fmt'] == 'apple-appattest'
|
28
|
+
raise IosAppAttest::AttestationError,
|
29
|
+
"Invalid attestation format: expected 'apple-appattest'"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Extract receipt from attestation statement
|
34
|
+
#
|
35
|
+
# This method extracts the App Store receipt from the attestation statement.
|
36
|
+
# The receipt is used for additional verification with Apple's servers.
|
37
|
+
#
|
38
|
+
# @param attestation [Hash] The decoded attestation object
|
39
|
+
# @return [String] The App Store receipt data
|
40
|
+
def extract_receipt(attestation)
|
41
|
+
attestation['attStmt']['receipt']
|
42
|
+
end
|
43
|
+
|
44
|
+
# Extract authentication data from attestation
|
45
|
+
#
|
46
|
+
# This method extracts the authentication data from the attestation object.
|
47
|
+
# The authentication data contains important information like the relying party ID hash,
|
48
|
+
# sign count, AAGUID, and credential ID needed for verification.
|
49
|
+
#
|
50
|
+
# @param attestation [Hash] The decoded attestation object
|
51
|
+
# @return [String] The authentication data used for identity verification
|
52
|
+
def extract_auth_data(attestation)
|
53
|
+
attestation['authData']
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|