Files
buzz/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift
Tom Brow c432a111ca feat(mobile): push notifications MVP (#6269)
This PR implements MVP, iOS-only,
[NIP-PL](8d2d0ff5ad/docs/nips/NIP-PL.md)-compliant
push notifications.

A relay with `BUZZ_PUSH_ENABLED` will send a push notification for any
message that appears in the in-app Notifications tab.

## Enrollment flow
The first time the client first connects to a relay with
`BUZZ_PUSH_ENABLED`:
```mermaid
sequenceDiagram
    autonumber
    participant App as Buzz iOS app
    participant iOS
    participant Relay as Buzz relay
    participant Attest as Apple App Attest
    participant Gateway as Push gateway

    App->>Relay: Fetch NIP-11 push capability
    Relay-->>App: Push profile, current relay public key, and limits

    par
        App->>iOS: Request notification permission
        iOS-->>App: Permission result
    and
        App->>iOS: Register for remote notifications
        iOS-->>App: Device token
    end

    App->>Gateway: Request installation challenge
    Gateway-->>App: Single-use challenge
    App->>Attest: Attest installation transcript
    Attest-->>App: Attestation proof
    App->>Gateway: Enroll device token and proof
    Gateway-->>App: Installation handle

    App->>Gateway: Request delegation challenge
    Gateway-->>App: Single-use challenge
    App->>Attest: Assert relay-key delegation
    Attest-->>App: Assertion
    App->>Gateway: Create delegation
    Gateway-->>App: Opaque endpoint grant

    App->>Relay: Publish encrypted push lease and filters
    Relay-->>App: Lease acknowledged
```

## Push-time flow

When a notification-eligible event is received by the relay:

```mermaid
%%{init: {
  "sequence": {
    "actorMargin": 20,
    "width": 110,
    "messageMargin": 18,
    "diagramMarginX": 8,
    "wrap": true
  }
}}%%
sequenceDiagram
    autonumber
    participant Relay as Buzz relay
    participant Gateway as Push gateway
    participant APNs as Apple Push<br/>Notification service
    participant iOS
    participant NSE as Notification service<br/>extension

    Relay->>Gateway: POST /v1/deliveries/apns<br/>opaque endpoint grant, request ID, expiry, NIP-98 authorization

    Gateway->>APNs: POST /3/device/{device-token}<br/>topic, request ID, expiry, constant mutable-content payload
    APNs-->>Gateway: 200 OK: request accepted
    Gateway-->>Relay: 200 OK: accepted status

    APNs-->>iOS: Notification: constant reconnect alert<br/>mutable-content = 1
    iOS->>NSE: Invoke extension<br/>original notification content

    NSE->>Relay: POST /query: subscription filters, limit 10<br/>NIP-98 authorization
    Relay-->>NSE: 200 OK: signed Nostr events<br/>kinds 9, 40002, 45001, or 45003

    NSE->>iOS: Complete notification: title, body, subtitle<br/>thread ID, exact-message target
```

relay → push gateway → APNs -> NSE -> Notification Center

## Known limitations

The APNs wake payload is intentionally constant and opaque: it contains
no originating community or message identifier, in keeping with the
implemented NIP-PL privacy design.

The Notification Service Extension must therefore reconnect to the relay
and resolve eligible messages after each wake. Around overlapping wakes,
timing boundaries, or resolution windows, notification presentation may
occasionally omit an expected message or display a message more than
once.

This best-effort behavior is deliberately accepted for the current
implementation and will be measured during the internal rollout to
determine whether the user experience is acceptable before any broader
deployment; the implementation does not claim exactly-once presentation.

## Validation

Live end-to-end hardware validation used an internal remotely hosted
development relay and push gateway, the APNs sandbox, and a physical
iPhone 12 mini:

- A second real Buzz client published a uniquely marked message through
the hosted relay.
- The relay matched the message and sent the constant opaque wake
through the hosted gateway. The gateway made an actual APNs request; no
`simctl push` or simulated notification was used.
- The iPhone received the notification on its lock screen. The
Notification Service Extension reconnected to the relay, fetched the
event, verified its ID and signature, and replaced the placeholder
content with the real notification title and body.
- After the app populated its shared presentation cache, a final marked
notification visibly showed the sender display name, sender avatar, and
hashtag-prefixed channel name.
- Tapping a lock-screen notification opened Buzz and exercised the
notification-response path and navigated to the corresponding message.

Final validation with a dogfood-signed artifact and production App
Attest/APNs configuration remains a release step.

## Independent pre-reviews

- **First pass:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=74ab9a93bb227f3e762568f1cf9fee66d7495b0edc3918735ff787238b9cc585)
found missing transient retries, executor-key rotation suppression,
duplicate installation renewal, and an unauthenticated challenge write
amplifier. These were resolved by [retry-safe
bootstrap](https://github.com/block/buzz/pull/6269/changes/12c66ea62)
and [authenticated renewal plus a cross-replica
quota](https://github.com/block/buzz/pull/6269/changes/8e5ece0bd).
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=d26d53daa4684669e2ed354638241f13f36c3a97027fe8b4dd738aff09038962)
found delegation generation burning and an edited applied migration,
resolved by [exact-generation
revocation](https://github.com/block/buzz/pull/6269/changes/c26d2159d)
and a [forward-only
migration](https://github.com/block/buzz/pull/6269/changes/956c1d099).
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=d43139138a0b15f806cbdbeeedd8f69d992cadf2e805876db6fdde6a34c7eda1)
found no blockers.
- **Exact-head re-review:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=a897721673459301b0cf26e8b85a1478d7ebbb56a4621f93d774c98d395b8f68),
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=fb2159f709ec68f74f7b21459acd76da0e8a7c5c0f3d469f99826b0cc2380849),
and
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=2ce2842910435f562e9d9cc718595848f281b122c94605e523a4b964254b8bfb)
independently returned **NO BLOCKERS** at `7eb3a650b`; k3-max also
revalidated every remediation and the endpoint-specific App Attest
enrollment bound.

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jordan Mecom <jm@squareup.com>
2026-08-28 15:15:09 -06:00

1314 lines
46 KiB
Swift

import CryptoKit
import Foundation
import Security
import XCTest
@testable import BuzzPushKit
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
final class BuzzDevPushEnrollmentDriverTests: XCTestCase {
private static let gatewayURL = URL(string: "http://push.example/")!
private static let relayURL = URL(string: "wss://relay.example/")!
private static let relayPubkey = String(repeating: "a", count: 64)
private static let firstChallengeId = "11111111-1111-4111-8111-111111111111"
private static let secondChallengeId = "33333333-3333-4333-8333-333333333333"
private static let installationHandle = "22222222-2222-4222-8222-222222222222"
private static let installationId = "000102030405060708090a0b0c0d0e0f"
private static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
private static let now: Int64 = 1_752_620_000
private static let expiresAt: Int64 = 1_752_624_000
private static let endpoint =
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
fileprivate static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo="
fileprivate static let attestation = Data("test-attestation".utf8).base64EncodedString()
fileprivate static let assertion = Data("buzz-dev-app-assertion-v1".utf8).base64EncodedString()
override func setUp() {
super.setUp()
URLProtocolStub.reset()
}
override func tearDown() {
URLProtocolStub.reset()
super.tearDown()
}
func testEnrollmentPinsTranscriptsAndPersistsOpaqueGrant() async throws {
let store = MemoryGrantStore()
let appAttest = RecordingAppAttest()
let driver = try makeDriver(store: store, appAttest: appAttest)
var challengeCount = 0
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example/"):
XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/nostr+json")
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": [
"keys": [
["id": "current", "pubkey": Self.relayPubkey, "current": true]
]
],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
challengeCount += 1
let body = try Self.body(request)
XCTAssertEqual(body["v"] as? Int, 1)
XCTAssertEqual(body.count, 1)
let id = challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId
return Self.response(
request,
status: 200,
json: [
"challenge_id": id,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/installations"):
let body = try Self.body(request)
XCTAssertEqual(body["endpoint"] as? String, Self.endpoint)
XCTAssertEqual(body["endpoint_epoch"] as? Int, 1)
XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt)
XCTAssertEqual(body["challenge_id"] as? String, Self.firstChallengeId)
XCTAssertEqual(body["challenge"] as? String, Self.challenge)
XCTAssertEqual(body["key_id"] as? String, Self.keyId)
XCTAssertEqual(body["attestation"] as? String, Self.attestation)
XCTAssertEqual(body["app_profile"] as? String, "buzz-ios-dogfood")
return Self.response(
request,
status: 201,
json: [
"installation_handle": Self.installationHandle,
"endpoint_epoch": 1,
"expires_at": Self.expiresAt,
]
)
case ("POST", "http://push.example/v1/delegations"):
let body = try Self.body(request)
XCTAssertEqual(body["relay_pubkey"] as? String, Self.relayPubkey)
XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle)
XCTAssertEqual(body["challenge_id"] as? String, Self.secondChallengeId)
XCTAssertEqual(body["challenge"] as? String, Self.challenge)
XCTAssertEqual(body["endpoint_epoch"] as? Int, 1)
XCTAssertEqual(body["not_before"] as? Int64, Self.now)
XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt)
XCTAssertEqual(body["assertion"] as? String, Self.assertion)
XCTAssertEqual(body["generation"] as? Int, 1)
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "opaque-grant"]
)
default:
XCTFail(
"Unexpected request \(request.httpMethod ?? "nil") \(request.url?.absoluteString ?? "nil")"
)
return Self.response(request, status: 500, json: [:])
}
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTAssertEqual(appAttest.clientData.count, 2)
XCTAssertEqual(record.relayOrigin, "wss://relay.example")
try assertMatchesVector(
"enroll",
actual: appAttest.clientData[0],
expectedSHA256: "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270",
fixture: makeFixtureTranscript(name: "enroll", replacements: [])
)
try assertMatchesVector(
"delegate",
actual: appAttest.clientData[1],
expectedSHA256: "f186db11cb53e4e80f09489c11dd18afc9b641683c3d72a67113c57d32fca323",
fixture: makeFixtureTranscript(
name: "delegate",
replacements: [
(Self.firstChallengeId, Self.secondChallengeId)
]
)
)
XCTAssertEqual(
record,
BuzzPushEndpointGrantRecord(
relayOrigin: "wss://relay.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
gatewayInstallationHandle: Self.installationHandle,
installationId: Self.installationId,
endpointGrant: "opaque-grant",
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
appProfile: "buzz-ios-dogfood",
endpointEpoch: 1,
generation: 1,
expiresAt: Self.expiresAt
)
)
XCTAssertEqual(store.saved, [record])
}
func testCommittedInstallationRecoversAfterFinalGrantSaveFailure() async throws {
let store = MemoryGrantStore(grantSaveFailuresRemaining: 1)
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
var challengeCount = 0
var installationCount = 0
var delegationCount = 0
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example/"):
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
challengeCount += 1
return Self.response(
request,
status: 200,
json: [
"challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/installations"):
installationCount += 1
return Self.response(
request,
status: 201,
json: [
"installation_handle": Self.installationHandle,
"endpoint_epoch": 1,
"expires_at": Self.expiresAt,
]
)
case ("POST", "http://push.example/v1/delegations"):
delegationCount += 1
let body = try Self.body(request)
XCTAssertEqual(body["generation"] as? Int, delegationCount)
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "opaque-grant-\(delegationCount)"]
)
default:
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
return Self.response(request, status: 500, json: [:])
}
}
do {
_ = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTFail("Expected the injected local save failure")
} catch {
XCTAssertEqual((error as NSError).domain, "MemoryGrantStore")
}
XCTAssertEqual(store.pending.first?.delegationGeneration, 1)
let recovered = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTAssertEqual(installationCount, 1)
XCTAssertEqual(delegationCount, 2)
XCTAssertEqual(recovered.generation, 2)
XCTAssertEqual(recovered.endpointGrant, "opaque-grant-2")
XCTAssertTrue(store.pending.isEmpty)
}
func testCommittedInstallationRecoversAfterResponseLoss() async throws {
let store = MemoryGrantStore()
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
var challengeCount = 0
var installationCount = 0
var firstInstallationBody: [String: Any]?
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example/"):
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
challengeCount += 1
return Self.response(
request,
status: 200,
json: [
"challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/installations"):
installationCount += 1
let body = try Self.body(request)
if installationCount == 1 {
firstInstallationBody = body
throw URLError(.networkConnectionLost)
}
XCTAssertTrue(
NSDictionary(dictionary: body).isEqual(to: try XCTUnwrap(firstInstallationBody))
)
return Self.response(
request,
status: 201,
json: [
"installation_handle": Self.installationHandle,
"endpoint_epoch": 1,
"expires_at": Self.expiresAt,
]
)
case ("POST", "http://push.example/v1/delegations"):
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "opaque-grant"]
)
default:
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
return Self.response(request, status: 500, json: [:])
}
}
do {
_ = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTFail("Expected the simulated lost installation response")
} catch {
XCTAssertEqual((error as NSError).domain, NSURLErrorDomain)
}
XCTAssertNil(store.pending.first?.gatewayInstallationHandle)
let recovered = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTAssertEqual(challengeCount, 2)
XCTAssertEqual(installationCount, 2)
XCTAssertEqual(recovered.endpointGrant, "opaque-grant")
XCTAssertTrue(store.pending.isEmpty)
}
func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws {
let relayURL = URL(string: "wss://relay.example:8443/")!
let store = MemoryGrantStore()
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
var challengeCount = 0
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example:8443/"):
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
challengeCount += 1
return Self.response(
request,
status: 200,
json: [
"challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/installations"):
return Self.response(
request,
status: 201,
json: [
"installation_handle": Self.installationHandle,
"endpoint_epoch": 1,
"expires_at": Self.expiresAt,
]
)
case ("POST", "http://push.example/v1/delegations"):
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "opaque-grant"]
)
default:
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
return Self.response(request, status: 500, json: [:])
}
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: relayURL
)
XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443")
}
func testLegacyGrantDecodesWithoutMetadataAuthority() throws {
let data = Data(
#"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"#
.utf8
)
let record = try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)
XCTAssertEqual(record.relayPubkey, Self.relayPubkey)
XCTAssertNil(record.relayMetadataPubkey)
}
func testRealAppAttestFailsLoudlyWhenUnsupported() async throws {
let service = RecordingDCAppAttestService(isSupported: false)
let provider = BuzzDCAppAttestProvider(
service: service,
keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId)
)
do {
_ = try await provider.prepareAttestation()
XCTFail("Expected App Attest to be unavailable")
} catch {
XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .appAttestUnsupported)
}
XCTAssertEqual(service.generateKeyCallCount, 0)
}
func testRealAppAttestGeneratesPersistsAndMapsAttestation() async throws {
let service = RecordingDCAppAttestService(
generatedKeyId: Self.keyId,
attestationObject: Data([0x01, 0x02, 0x03])
)
let keyIdStore = MemoryAppAttestKeyIdStore()
let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore)
let clientData = Data("enrollment transcript".utf8)
let prepared = try await provider.prepareAttestation()
let attestation = try await provider.attestation(prepared, clientData: clientData)
XCTAssertEqual(prepared, BuzzDevAttestation(keyId: Self.keyId, attestation: ""))
XCTAssertEqual(keyIdStore.savedKeyIds, [Self.keyId])
XCTAssertEqual(attestation.keyId, Self.keyId)
XCTAssertEqual(attestation.attestation, Data([0x01, 0x02, 0x03]).base64EncodedString())
XCTAssertEqual(service.attestedKeyIds, [Self.keyId])
XCTAssertEqual(
service.attestationClientDataHashes,
[Data(SHA256.hash(data: clientData))]
)
}
func testRealAppAttestAssertionReusesStoredKeyAndMapsObject() async throws {
let service = RecordingDCAppAttestService(assertionObject: Data([0x04, 0x05, 0x06]))
let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId)
let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore)
let clientData = Data("delegation transcript".utf8)
let assertion = try await provider.assertion(clientData: clientData)
XCTAssertEqual(assertion, Data([0x04, 0x05, 0x06]).base64EncodedString())
XCTAssertEqual(service.assertedKeyIds, [Self.keyId])
XCTAssertEqual(
service.assertionClientDataHashes,
[Data(SHA256.hash(data: clientData))]
)
XCTAssertEqual(service.generateKeyCallCount, 0)
}
func testRealAppAttestRejectsInvalidGeneratedKeyBeforePersistence() async throws {
for invalidKeyId in [
"not-a-key-id",
String(Self.keyId.dropLast(2)) + "p=",
Data(repeating: 0xAA, count: 31).base64EncodedString(),
Data(repeating: 0xAA, count: 33).base64EncodedString(),
] {
let service = RecordingDCAppAttestService(generatedKeyId: invalidKeyId)
let keyIdStore = MemoryAppAttestKeyIdStore()
let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore)
do {
_ = try await provider.prepareAttestation()
XCTFail("Accepted invalid generated key ID: \(invalidKeyId)")
} catch {
XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId)
}
XCTAssertTrue(keyIdStore.savedKeyIds.isEmpty)
}
}
func testRealAppAttestRejectsMismatchedPreparedKey() async throws {
let service = RecordingDCAppAttestService()
let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId)
let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore)
let otherKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString()
do {
_ = try await provider.attestation(
BuzzDevAttestation(keyId: otherKeyId, attestation: ""),
clientData: Data("enrollment transcript".utf8)
)
XCTFail("Expected the prepared key ID to match persistent state")
} catch {
XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId)
}
XCTAssertTrue(service.attestedKeyIds.isEmpty)
}
func testRealAppAttestForwardsServiceErrors() async throws {
let expected = NSError(domain: "DeviceCheckTest", code: 41)
let service = RecordingDCAppAttestService(error: expected)
let provider = BuzzDCAppAttestProvider(
service: service,
keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId)
)
do {
_ = try await provider.assertion(clientData: Data("delegation transcript".utf8))
XCTFail("Expected the DeviceCheck error")
} catch {
XCTAssertEqual((error as NSError).domain, expected.domain)
XCTAssertEqual((error as NSError).code, expected.code)
}
}
func testKeychainStoreReadsKeyIdAndIncludesAccessGroup() throws {
var capturedQuery: [String: Any] = [:]
let store = BuzzAppAttestKeyIdKeychainStore(
accessGroup: "group.buzz",
copyMatching: { query, result in
capturedQuery = query as! [String: Any]
result?.pointee = Data(Self.keyId.utf8) as CFData
return errSecSuccess
}
)
XCTAssertEqual(try store.keyId(), Self.keyId)
XCTAssertEqual(
capturedQuery[kSecClass as String] as? String, kSecClassGenericPassword as String)
XCTAssertEqual(capturedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest")
XCTAssertEqual(capturedQuery[kSecAttrAccount as String] as? String, "key-id-v1")
XCTAssertEqual(capturedQuery[kSecAttrAccessGroup as String] as? String, "group.buzz")
XCTAssertEqual(capturedQuery[kSecReturnData as String] as? Bool, true)
XCTAssertEqual(capturedQuery[kSecMatchLimit as String] as? String, kSecMatchLimitOne as String)
}
func testKeychainStoreReturnsNilOnMissAndRejectsInvalidData() throws {
let missing = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
copyMatching: { _, _ in errSecItemNotFound }
)
XCTAssertNil(try missing.keyId())
for invalidKeyId in [
"bad",
String(Self.keyId.dropLast(2)) + "p=",
Data(repeating: 0xAA, count: 31).base64EncodedString(),
Data(repeating: 0xAA, count: 33).base64EncodedString(),
] {
let invalid = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
copyMatching: { _, result in
result?.pointee = Data(invalidKeyId.utf8) as CFData
return errSecSuccess
}
)
XCTAssertThrowsError(try invalid.keyId(), "Accepted invalid key ID: \(invalidKeyId)") {
XCTAssertEqual($0 as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId)
}
}
}
func testKeychainStoreUpdatesExistingKeyId() throws {
var updatedQuery: [String: Any] = [:]
var updatedValues: [String: Any] = [:]
var addCallCount = 0
let store = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
update: { query, values in
updatedQuery = query as! [String: Any]
updatedValues = values as! [String: Any]
return errSecSuccess
},
add: { _, _ in
addCallCount += 1
return errSecSuccess
}
)
try store.saveKeyId(Self.keyId)
XCTAssertEqual(updatedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest")
XCTAssertEqual(updatedValues[kSecValueData as String] as? Data, Data(Self.keyId.utf8))
XCTAssertEqual(addCallCount, 0)
}
func testKeychainStoreAddsMissingKeyIdWithDeviceOnlyAccessibility() throws {
var addedItem: [String: Any] = [:]
let store = BuzzAppAttestKeyIdKeychainStore(
accessGroup: "group.buzz",
update: { _, _ in errSecItemNotFound },
add: { item, _ in
addedItem = item as! [String: Any]
return errSecSuccess
}
)
try store.saveKeyId(Self.keyId)
XCTAssertEqual(addedItem[kSecValueData as String] as? Data, Data(Self.keyId.utf8))
XCTAssertEqual(
addedItem[kSecAttrAccessible as String] as? String,
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String
)
XCTAssertEqual(addedItem[kSecAttrAccessGroup as String] as? String, "group.buzz")
}
func testKeychainStoreSurfacesReadUpdateAndAddErrors() throws {
let readFailure = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
copyMatching: { _, _ in errSecInteractionNotAllowed }
)
XCTAssertThrowsError(try readFailure.keyId()) {
XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed))
}
let updateFailure = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
update: { _, _ in errSecInteractionNotAllowed }
)
XCTAssertThrowsError(try updateFailure.saveKeyId(Self.keyId)) {
XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed))
}
let addFailure = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
update: { _, _ in errSecItemNotFound },
add: { _, _ in errSecDuplicateItem }
)
XCTAssertThrowsError(try addFailure.saveKeyId(Self.keyId)) {
XCTAssertEqual(($0 as NSError).code, Int(errSecDuplicateItem))
}
}
func testReusesPersistedUnexpiredGrant() async throws {
let existing = BuzzPushEndpointGrantRecord(
relayOrigin: "wss://relay.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
installationId: Self.installationId,
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
appProfile: "buzz-ios-dogfood",
endpointEpoch: 1,
generation: 1,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
guard request.httpMethod == "GET" else {
XCTFail("Persisted grant reuse must not call the gateway")
return Self.response(request, status: 500, json: [:])
}
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTAssertEqual(record, existing)
XCTAssertEqual(store.saved, [existing])
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws {
let existing = BuzzPushEndpointGrantRecord(
relayOrigin: "wss://first.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
gatewayInstallationHandle: Self.installationHandle,
installationId: String(repeating: "f", count: 32),
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
appProfile: "buzz-ios-dogfood",
endpointEpoch: 1,
generation: 4,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.absoluteString, "https://second.example/")
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: URL(string: "wss://second.example/")!
)
XCTAssertEqual(record.relayOrigin, "wss://second.example")
XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle)
XCTAssertEqual(record.endpointGrant, existing.endpointGrant)
XCTAssertEqual(record.generation, existing.generation)
XCTAssertEqual(record.installationId, Self.installationId)
XCTAssertNotEqual(record.installationId, existing.installationId)
XCTAssertEqual(store.saved.count, 2)
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testSecondRelayKeyReusesAttestedInstallationAndCreatesOnlyDelegation() async throws {
let secondRelayPubkey = String(repeating: "b", count: 64)
let existing = BuzzPushEndpointGrantRecord(
relayOrigin: "wss://first.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
gatewayInstallationHandle: Self.installationHandle,
installationId: String(repeating: "f", count: 32),
endpointGrant: "first-relay-grant",
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
appProfile: "buzz-ios-dogfood",
endpointEpoch: 1,
generation: 7,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let appAttest = RecordingAppAttest()
let driver = try makeDriver(store: store, appAttest: appAttest)
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://second.example/"):
return Self.response(
request,
status: 200,
json: [
"self": secondRelayPubkey,
"push": ["keys": [["pubkey": secondRelayPubkey, "current": true]]],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
return Self.response(
request,
status: 200,
json: [
"challenge_id": Self.firstChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/delegations"):
let body = try Self.body(request)
XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle)
XCTAssertEqual(body["relay_pubkey"] as? String, secondRelayPubkey)
XCTAssertEqual(body["generation"] as? Int, 1)
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "second-relay-grant"]
)
case ("POST", "http://push.example/v1/installations"):
XCTFail("A second relay must not create a duplicate APNs installation")
return Self.response(request, status: 500, json: [:])
default:
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
return Self.response(request, status: 500, json: [:])
}
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: URL(string: "wss://second.example/")!
)
XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle)
XCTAssertEqual(record.relayPubkey, secondRelayPubkey)
XCTAssertEqual(record.endpointGrant, "second-relay-grant")
XCTAssertEqual(record.generation, 1)
XCTAssertEqual(appAttest.clientData.count, 1)
XCTAssertEqual(store.saved.count, 2)
}
func testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws {
let existing = BuzzPushEndpointGrantRecord(
relayOrigin: "wss://relay.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
gatewayInstallationHandle: Self.installationHandle,
installationId: Self.installationId,
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
appProfile: "buzz-ios-dogfood",
endpointEpoch: 1,
generation: 7,
expiresAt: Self.now + 300
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(
store: store,
appAttest: RecordingAppAttest(),
installationIdBytes: {
XCTFail("Grant refresh must reuse the persisted installation id")
return Data(repeating: 0xFF, count: 16)
}
)
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example/"):
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
return Self.response(
request,
status: 200,
json: [
"challenge_id": Self.firstChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/installations"):
XCTFail("An expiring installation must renew through authenticated delegation")
return Self.response(request, status: 500, json: [:])
case ("POST", "http://push.example/v1/delegations"):
let body = try Self.body(request)
XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle)
XCTAssertEqual(body["generation"] as? Int, 8)
XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt)
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "refreshed-grant"]
)
default:
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
return Self.response(request, status: 500, json: [:])
}
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTAssertEqual(record.installationId, Self.installationId)
XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle)
XCTAssertEqual(record.generation, 8)
XCTAssertEqual(record.expiresAt, Self.expiresAt)
XCTAssertEqual(record.endpointGrant, "refreshed-grant")
}
func testRejectsMultipleCurrentRelayKeysBeforeGatewayEnrollment() async throws {
let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": [
"keys": [
["pubkey": Self.relayPubkey, "current": true],
["pubkey": String(repeating: "b", count: 64), "current": true],
]
],
]
)
}
do {
_ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL)
XCTFail("Expected an invalid relay descriptor")
} catch {
XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidRelayDescriptor)
}
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testTracksRelayMetadataAuthoritySeparatelyFromPushDelegationKey() async throws {
let pushPubkey = String(repeating: "b", count: 64)
let oldMetadataPubkey = String(repeating: "c", count: 64)
let deviceToken = Data((1...32).map(UInt8.init))
let existing = BuzzPushEndpointGrantRecord(
relayOrigin: "wss://relay.example",
relayPubkey: pushPubkey,
relayMetadataPubkey: oldMetadataPubkey,
installationId: Self.installationId,
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: deviceToken)),
appProfile: "buzz-ios-dogfood",
endpointEpoch: 1,
generation: 1,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": [
"keys": [
["pubkey": pushPubkey, "current": true]
]
],
]
)
}
let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL)
XCTAssertEqual(record.relayPubkey, pushPubkey)
XCTAssertEqual(record.relayMetadataPubkey, Self.relayPubkey)
XCTAssertNotEqual(record.relayMetadataPubkey, oldMetadataPubkey)
XCTAssertEqual(record.endpointGrant, existing.endpointGrant)
XCTAssertEqual(store.saved, [record])
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testMissingRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws {
let deviceToken = Data((1...32).map(UInt8.init))
let existing = BuzzPushEndpointGrantRecord(
relayOrigin: "wss://relay.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
installationId: Self.installationId,
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: deviceToken)),
appProfile: "buzz-ios-dogfood",
endpointEpoch: 1,
generation: 1,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
Self.response(
request,
status: 200,
json: [
"push": [
"keys": [["pubkey": Self.relayPubkey, "current": true]]
]
]
)
}
let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL)
XCTAssertEqual(record.relayPubkey, Self.relayPubkey)
XCTAssertNil(record.relayMetadataPubkey)
XCTAssertEqual(record.endpointGrant, existing.endpointGrant)
XCTAssertEqual(store.saved, [record])
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testMalformedRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws {
let deviceToken = Data((1...32).map(UInt8.init))
let existing = BuzzPushEndpointGrantRecord(
relayOrigin: "wss://relay.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
installationId: Self.installationId,
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: deviceToken)),
appProfile: "buzz-ios-dogfood",
endpointEpoch: 1,
generation: 1,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
Self.response(
request,
status: 200,
json: [
"self": 42,
"push": [
"keys": [["pubkey": Self.relayPubkey, "current": true]]
],
]
)
}
let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL)
XCTAssertEqual(record.relayPubkey, Self.relayPubkey)
XCTAssertNil(record.relayMetadataPubkey)
XCTAssertEqual(record.endpointGrant, existing.endpointGrant)
XCTAssertEqual(store.saved, [record])
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testFailsLoudlyOnUnexpectedGatewayStatus() async throws {
let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
if request.httpMethod == "GET" {
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
}
return Self.response(request, status: 400, json: ["error": "invalid_request"])
}
do {
_ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL)
XCTFail("Expected the gateway error")
} catch let error as BuzzDevPushEnrollmentError {
XCTAssertEqual(
error,
.unexpectedStatus(
route: "v1/installations/challenges",
expected: 200,
actual: 400,
body: "{\"error\":\"invalid_request\"}"
)
)
}
}
private func makeDriver(
store: BuzzPushEndpointGrantStore,
appAttest: BuzzDevAppAttesting,
installationIdBytes: @escaping () throws -> Data = {
Data(0..<16)
}
) throws -> BuzzDevPushEnrollmentDriver {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [URLProtocolStub.self]
return try BuzzDevPushEnrollmentDriver(
gatewayBaseURL: Self.gatewayURL,
store: store,
session: URLSession(configuration: configuration),
appAttest: appAttest,
now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) },
lifetimeSeconds: Self.expiresAt - Self.now,
installationIdBytes: installationIdBytes
)
}
private func makeFixtureTranscript(
name: String,
replacements: [(String, String)]
) throws -> (bytes: Data, sha256: String) {
let fixture = try Self.fixture()
let vector = try XCTUnwrap(fixture.vectors.first { $0.name == name })
let transcript = replacements.reduce(vector.transcript) {
$0.replacingOccurrences(of: $1.0, with: $1.1)
}
return (Data(transcript.utf8), Self.hex(SHA256.hash(data: Data(transcript.utf8))))
}
private func assertMatchesVector(
_ name: String,
actual: Data,
expectedSHA256: String,
fixture: (bytes: Data, sha256: String),
file: StaticString = #filePath,
line: UInt = #line
) throws {
XCTAssertEqual(
fixture.sha256,
expectedSHA256,
"\(name) substituted gateway vector SHA-256",
file: file,
line: line
)
XCTAssertEqual(
actual, fixture.bytes, "\(name) exact transcript bytes", file: file, line: line)
XCTAssertEqual(
Self.hex(SHA256.hash(data: actual)),
fixture.sha256,
"\(name) transcript SHA-256",
file: file,
line: line
)
}
private struct Fixture: Decodable {
struct Vector: Decodable {
let name: String
let transcript: String
}
let vectors: [Vector]
}
private static func fixture() throws -> Fixture {
let path = try XCTUnwrap(
Bundle.module.url(
forResource: "app_attest_transcripts",
withExtension: "json"
),
"missing bundled gateway transcript fixture app_attest_transcripts.json in \(Bundle.module.bundleURL.path)"
)
let data = try Data(contentsOf: path)
return try JSONDecoder().decode(Fixture.self, from: data)
}
private static func body(_ request: URLRequest) throws -> [String: Any] {
let data: Data
if let httpBody = request.httpBody {
data = httpBody
} else {
let stream = try XCTUnwrap(request.httpBodyStream)
stream.open()
defer { stream.close() }
var bytes = Data()
var buffer = [UInt8](repeating: 0, count: 1_024)
while true {
let count = stream.read(&buffer, maxLength: buffer.count)
if count < 0 {
throw try XCTUnwrap(stream.streamError)
}
if count == 0 { break }
bytes.append(buffer, count: count)
}
data = bytes
}
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
private static func response(
_ request: URLRequest,
status: Int,
json: [String: Any]
) -> (HTTPURLResponse, Data) {
let data = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys])
let response = HTTPURLResponse(
url: request.url!,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (response, data)
}
private static func hex<D: Sequence>(_ data: D) -> String where D.Element == UInt8 {
data.map { String(format: "%02x", $0) }.joined()
}
}
private final class MemoryGrantStore: BuzzPushEndpointGrantStore {
var saved: [BuzzPushEndpointGrantRecord]
var pending: [BuzzPushPendingEnrollmentRecord] = []
var grantSaveFailuresRemaining: Int
init(
records: [BuzzPushEndpointGrantRecord] = [],
grantSaveFailuresRemaining: Int = 0
) {
saved = records
self.grantSaveFailuresRemaining = grantSaveFailuresRemaining
}
func records() throws -> [BuzzPushEndpointGrantRecord] { saved }
func save(_ record: BuzzPushEndpointGrantRecord) throws {
if grantSaveFailuresRemaining > 0 {
grantSaveFailuresRemaining -= 1
throw NSError(domain: "MemoryGrantStore", code: 1)
}
saved.removeAll {
$0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile
}
saved.append(record)
}
func pendingEnrollment(
relayOrigin: String,
appProfile: String
) throws -> BuzzPushPendingEnrollmentRecord? {
pending.first {
$0.relayOrigin == relayOrigin && $0.appProfile == appProfile
}
}
func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws {
pending.removeAll {
$0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile
}
pending.append(record)
}
func removePendingEnrollment(relayOrigin: String, appProfile: String) throws {
pending.removeAll {
$0.relayOrigin == relayOrigin && $0.appProfile == appProfile
}
}
}
private final class RecordingAppAttest: BuzzDevAppAttesting {
var clientData: [Data] = []
func prepareAttestation() async throws -> BuzzDevAttestation {
BuzzDevAttestation(
keyId: BuzzDevPushEnrollmentDriverTests.keyId,
attestation: BuzzDevPushEnrollmentDriverTests.attestation
)
}
func attestation(
_ prepared: BuzzDevAttestation,
clientData: Data
) async throws -> BuzzDevAttestation {
self.clientData.append(clientData)
return prepared
}
func assertion(clientData: Data) async throws -> String {
self.clientData.append(clientData)
return BuzzDevPushEnrollmentDriverTests.assertion
}
}
private final class MemoryAppAttestKeyIdStore: BuzzAppAttestKeyIdStoring {
var keyIdValue: String?
var savedKeyIds: [String] = []
init(keyId: String? = nil) {
keyIdValue = keyId
}
func keyId() throws -> String? { keyIdValue }
func saveKeyId(_ keyId: String) throws {
savedKeyIds.append(keyId)
keyIdValue = keyId
}
}
private final class RecordingDCAppAttestService: BuzzDCAppAttestServicing {
let isSupported: Bool
let generatedKeyId: String
let attestationObject: Data
let assertionObject: Data
let error: Error?
var generateKeyCallCount = 0
var attestedKeyIds: [String] = []
var attestationClientDataHashes: [Data] = []
var assertedKeyIds: [String] = []
var assertionClientDataHashes: [Data] = []
init(
isSupported: Bool = true,
generatedKeyId: String = BuzzDevPushEnrollmentDriverTests.keyId,
attestationObject: Data = Data("attestation-object".utf8),
assertionObject: Data = Data("assertion-object".utf8),
error: Error? = nil
) {
self.isSupported = isSupported
self.generatedKeyId = generatedKeyId
self.attestationObject = attestationObject
self.assertionObject = assertionObject
self.error = error
}
func generateKey() async throws -> String {
generateKeyCallCount += 1
if let error { throw error }
return generatedKeyId
}
func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data {
attestedKeyIds.append(keyId)
attestationClientDataHashes.append(clientDataHash)
if let error { throw error }
return attestationObject
}
func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data {
assertedKeyIds.append(keyId)
assertionClientDataHashes.append(clientDataHash)
if let error { throw error }
return assertionObject
}
}
private final class URLProtocolStub: URLProtocol, @unchecked Sendable {
static let lock = NSLock()
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
static var requests: [URLRequest] = []
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Self.lock.lock()
Self.requests.append(request)
let handler = Self.handler
Self.lock.unlock()
do {
let (response, data) =
try handler?(request)
?? {
throw URLError(.unsupportedURL)
}()
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
static func reset() {
lock.lock()
handler = nil
requests = []
lock.unlock()
}
}