mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Buzz push currently selects an application profile, such as `dogfood`, during client registration. We want each gateway to serve one Apple app configured by the operator instead. This removes profile selection from the client, relay, and gateway, while allowing internal and public gateways to use their own Apple identities. This PR implements that model in the Buzz source and upstream deployment chart. Discovery, enrollment, grants, and leases no longer carry an application-profile selector. Each gateway has one configured Apple application and APNs transport. The protocol changes are incompatible with the old registration format, so relays advertise `buzz-push-v1` instead of `nip-pl`. Old clients do not discover the new push capability. Updated clients use separate Keychain records and register again. The [push notes](https://github.com/block/buzz/blob/push-capability-v1/docs/buzz-push.md) describe the experimental contract. Landing this PR is the first step. Publish the matching gateway image and upstream chart, then land the [shared chart update](https://github.com/squareup/cash-helm-charts/pull/5740) and [gateway deployment update](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/1085). Before cutover, stop old gateway instances and check legacy registration state. Migrations refuse to proceed if old gateway installations or active relay leases remain; they do not delete that state. Physical-device verification remains necessary before production activation. ### Validation Gateway and focused Flutter push tests pass, as do the Swift package, simulator build, and native notification/enrichment tests. A local relay advertises `buzz-push-v1` without `nip-pl` or `app_profiles`. The PostgreSQL suite passed 868 tests; its two migration-test failures were corrected and pass a focused rerun together with the gateway cutover guard. Chart rendering and release-contract checks pass. Desktop JavaScript tests, desktop and web builds, native desktop compilation, mobile analysis/tests, and desktop native tests pass. The full Rust unit lane and prescribed `just test` command pass, including workspace integration tests against task-isolated local services. The production overlay now requires an explicitly supplied APNs topic, with omission and rendered-value regression coverage. The corrected Swift checksum assertion passes the complete package suite: 78 XCTest tests and 42 Swift Testing tests. No physical-device enrollment or production cutover is claimed. Local merge validation: the embedded migration test and formatting pass after reserving profile migration 0060 behind rollback migration 0059. The local pre-push Rust lane failed ACP timing/steering tests (`idle_resets_on_stdout_activity`, `keepalive_resets_idle_past_deadline`, and the failed/missing-outcome steering tests). ACP and its dependency manifests are unchanged by this branch; all four pass in an isolated single-thread rerun. The documented local-check exception skips only the Rust pre-push lane for publication; required CI remains mandatory. Mobile and desktop pre-push lanes passed. Combined migration validation: after rollback 0059 landed, the main merge preserves profile migration 0060 and both embedded migration assertions. The focused migration test and formatting pass. The next local push again failed only the unchanged ACP idle/keepalive timing tests (1053 passed, 2 failed, 3 ignored); other lanes passed. Publication uses the same documented local Rust-lane exception; required CI remains mandatory. Follow-up review fix: removed the legacy profile column from the copied-lease fixture. All four event-follow-up contract tests pass on isolated PostgreSQL databases, covering desired and migrated schemas in dual and app-only modes. Formatting passes. Publication retains the prior passing local-lane evidence and uses the authorized pre-push exception for the known unrelated ACP timing failures; required CI remains mandatory. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
1420 lines
50 KiB
Swift
1420 lines
50 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 gatewayOrigin = "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)
|
|
XCTAssertNil(body["app_profile"])
|
|
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.gatewayOrigin, Self.gatewayOrigin)
|
|
XCTAssertEqual(record.relayOrigin, "wss://relay.example")
|
|
try assertMatchesVector(
|
|
"enroll",
|
|
actual: appAttest.clientData[0],
|
|
expectedSHA256: "792966649266bfcebdb79de54e8ed3880746f58efd77427a0f43e88c650d79d0",
|
|
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(
|
|
gatewayOrigin: Self.gatewayOrigin,
|
|
relayOrigin: "wss://relay.example",
|
|
relayPubkey: Self.relayPubkey,
|
|
relayMetadataPubkey: Self.relayPubkey,
|
|
gatewayInstallationHandle: Self.installationHandle,
|
|
appAttestKeyId: Self.keyId,
|
|
installationId: Self.installationId,
|
|
endpointGrant: "opaque-grant",
|
|
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
|
|
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 testResponseLostEnrollmentSurvivesTokenRotationAndAnotherReplayFailure() async throws {
|
|
let store = MemoryGrantStore()
|
|
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
|
|
let rotatedToken = Data(repeating: 42, count: 32)
|
|
let rotatedHandle = "44444444-4444-4444-8444-444444444444"
|
|
var oldRequests = 0
|
|
var newRequests = 0
|
|
var firstBody: [String: Any]?
|
|
var delegatedHandles: [String] = []
|
|
URLProtocolStub.handler = { request in
|
|
switch (request.httpMethod, request.url?.absoluteString) {
|
|
case ("GET", "https://relay.example/"):
|
|
return Self.response(request, status: 200, json: [
|
|
"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"):
|
|
let body = try Self.body(request)
|
|
let oldEndpoint = body["endpoint"] as? String == Self.endpoint
|
|
if oldEndpoint {
|
|
oldRequests += 1
|
|
if let firstBody {
|
|
XCTAssertTrue(NSDictionary(dictionary: body).isEqual(to: firstBody))
|
|
} else {
|
|
firstBody = body
|
|
}
|
|
if oldRequests <= 2 { throw URLError(.networkConnectionLost) }
|
|
} else {
|
|
newRequests += 1
|
|
XCTAssertEqual(body["endpoint"] as? String, Self.hex(rotatedToken))
|
|
XCTAssertEqual(delegatedHandles, [Self.installationHandle])
|
|
}
|
|
return Self.response(request, status: 201, json: [
|
|
"installation_handle": oldEndpoint ? Self.installationHandle : rotatedHandle,
|
|
"endpoint_epoch": 1,
|
|
"expires_at": Self.expiresAt,
|
|
])
|
|
case ("POST", "http://push.example/v1/delegations"):
|
|
let handle = try XCTUnwrap(Self.body(request)["installation_handle"] as? String)
|
|
delegatedHandles.append(handle)
|
|
return Self.response(request, status: 201, json: ["endpoint_grant": "grant-\(handle)"])
|
|
default:
|
|
XCTFail("Unexpected request, including any migration/revocation route: \(request.url!)")
|
|
return Self.response(request, status: 500, json: [:])
|
|
}
|
|
}
|
|
|
|
for token in [Data((1...32).map(UInt8.init)), rotatedToken] {
|
|
do {
|
|
_ = try await driver.enroll(deviceToken: token, relayURL: Self.relayURL)
|
|
XCTFail("Expected response loss")
|
|
} catch {
|
|
XCTAssertEqual((error as NSError).domain, NSURLErrorDomain)
|
|
}
|
|
XCTAssertEqual(store.pending.first?.endpoint, Self.endpoint)
|
|
XCTAssertTrue(store.saved.isEmpty)
|
|
}
|
|
let grant = try await driver.enroll(deviceToken: rotatedToken, relayURL: Self.relayURL)
|
|
XCTAssertEqual(oldRequests, 3)
|
|
XCTAssertEqual(newRequests, 1)
|
|
XCTAssertEqual(delegatedHandles, [Self.installationHandle, rotatedHandle])
|
|
XCTAssertEqual(grant.endpointHash, Self.hex(SHA256.hash(data: rotatedToken)))
|
|
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 testLegacyGrantWithoutGatewayOriginIsRejected() throws {
|
|
let data = Data(
|
|
#"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"#
|
|
.utf8
|
|
)
|
|
|
|
XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data))
|
|
}
|
|
|
|
func testGrantWithoutAppAttestKeyIsRejected() throws {
|
|
let data = Data(
|
|
#"{"gatewayOrigin":"https://push.example","relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"#
|
|
.utf8
|
|
)
|
|
|
|
XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data))
|
|
}
|
|
|
|
func testOtherGatewayGrantIsNeitherExportedNorMigrated() throws {
|
|
let other = BuzzPushEndpointGrantRecord(
|
|
gatewayOrigin: "https://other-push.example",
|
|
relayOrigin: "wss://relay.example",
|
|
relayPubkey: Self.relayPubkey,
|
|
appAttestKeyId: Self.keyId,
|
|
installationId: Self.installationId,
|
|
endpointGrant: "other-gateway-grant",
|
|
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
|
|
endpointEpoch: 1,
|
|
generation: 1,
|
|
expiresAt: Self.expiresAt
|
|
)
|
|
let store = MemoryGrantStore(records: [other])
|
|
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
|
|
|
|
XCTAssertEqual(try driver.endpointGrants(), [])
|
|
XCTAssertEqual(store.saved, [other])
|
|
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
|
|
}
|
|
|
|
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 testRealAppAttestAssertionUsesRequestedKeyAndMapsObject() 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(keyId: Self.keyId, 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 testRealAppAttestAssertionUsesInstallationKeyInsteadOfLatestStoredKey() async throws {
|
|
let installationKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString()
|
|
let service = RecordingDCAppAttestService(assertionObject: Data([0x07]))
|
|
let provider = BuzzDCAppAttestProvider(
|
|
service: service,
|
|
keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId)
|
|
)
|
|
|
|
_ = try await provider.assertion(
|
|
keyId: installationKeyId,
|
|
clientData: Data("retired installation transcript".utf8)
|
|
)
|
|
|
|
XCTAssertEqual(service.assertedKeyIds, [installationKeyId])
|
|
}
|
|
|
|
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(
|
|
keyId: Self.keyId,
|
|
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 testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws {
|
|
let existing = BuzzPushEndpointGrantRecord(
|
|
gatewayOrigin: Self.gatewayOrigin,
|
|
relayOrigin: "wss://first.example",
|
|
relayPubkey: Self.relayPubkey,
|
|
relayMetadataPubkey: Self.relayPubkey,
|
|
gatewayInstallationHandle: Self.installationHandle,
|
|
appAttestKeyId: Self.keyId,
|
|
installationId: String(repeating: "f", count: 32),
|
|
endpointGrant: "existing-grant",
|
|
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
|
|
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(
|
|
gatewayOrigin: Self.gatewayOrigin,
|
|
relayOrigin: "wss://first.example",
|
|
relayPubkey: Self.relayPubkey,
|
|
relayMetadataPubkey: Self.relayPubkey,
|
|
gatewayInstallationHandle: Self.installationHandle,
|
|
appAttestKeyId: Self.keyId,
|
|
installationId: String(repeating: "f", count: 32),
|
|
endpointGrant: "first-relay-grant",
|
|
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
|
|
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(
|
|
gatewayOrigin: Self.gatewayOrigin,
|
|
relayOrigin: "wss://relay.example",
|
|
relayPubkey: Self.relayPubkey,
|
|
relayMetadataPubkey: Self.relayPubkey,
|
|
gatewayInstallationHandle: Self.installationHandle,
|
|
appAttestKeyId: Self.keyId,
|
|
installationId: Self.installationId,
|
|
endpointGrant: "existing-grant",
|
|
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
|
|
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(
|
|
gatewayOrigin: Self.gatewayOrigin,
|
|
relayOrigin: "wss://relay.example",
|
|
relayPubkey: pushPubkey,
|
|
relayMetadataPubkey: oldMetadataPubkey,
|
|
appAttestKeyId: Self.keyId,
|
|
installationId: Self.installationId,
|
|
endpointGrant: "existing-grant",
|
|
endpointHash: Self.hex(SHA256.hash(data: deviceToken)),
|
|
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(
|
|
gatewayOrigin: Self.gatewayOrigin,
|
|
relayOrigin: "wss://relay.example",
|
|
relayPubkey: Self.relayPubkey,
|
|
relayMetadataPubkey: Self.relayPubkey,
|
|
appAttestKeyId: Self.keyId,
|
|
installationId: Self.installationId,
|
|
endpointGrant: "existing-grant",
|
|
endpointHash: Self.hex(SHA256.hash(data: deviceToken)),
|
|
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(
|
|
gatewayOrigin: Self.gatewayOrigin,
|
|
relayOrigin: "wss://relay.example",
|
|
relayPubkey: Self.relayPubkey,
|
|
relayMetadataPubkey: Self.relayPubkey,
|
|
appAttestKeyId: Self.keyId,
|
|
installationId: Self.installationId,
|
|
endpointGrant: "existing-grant",
|
|
endpointHash: Self.hex(SHA256.hash(data: deviceToken)),
|
|
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(
|
|
gatewayBaseURL: URL = BuzzDevPushEnrollmentDriverTests.gatewayURL,
|
|
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: gatewayBaseURL,
|
|
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
|
|
var pendingRemoveFailuresRemaining: Int
|
|
init(
|
|
records: [BuzzPushEndpointGrantRecord] = [],
|
|
pending: [BuzzPushPendingEnrollmentRecord] = [],
|
|
grantSaveFailuresRemaining: Int = 0,
|
|
pendingRemoveFailuresRemaining: Int = 0
|
|
) {
|
|
saved = records
|
|
self.pending = pending
|
|
self.grantSaveFailuresRemaining = grantSaveFailuresRemaining
|
|
self.pendingRemoveFailuresRemaining = pendingRemoveFailuresRemaining
|
|
}
|
|
func records() throws -> [BuzzPushEndpointGrantRecord] { saved }
|
|
func save(_ record: BuzzPushEndpointGrantRecord) throws {
|
|
if grantSaveFailuresRemaining > 0 {
|
|
grantSaveFailuresRemaining -= 1
|
|
throw NSError(domain: "MemoryGrantStore", code: 1)
|
|
}
|
|
saved.removeAll {
|
|
$0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin
|
|
}
|
|
saved.append(record)
|
|
}
|
|
func pendingEnrollment(
|
|
gatewayOrigin: String,
|
|
relayOrigin: String
|
|
) throws -> BuzzPushPendingEnrollmentRecord? {
|
|
pending.first {
|
|
$0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin
|
|
}
|
|
}
|
|
func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws {
|
|
pending.removeAll {
|
|
$0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin
|
|
}
|
|
pending.append(record)
|
|
}
|
|
func removePendingEnrollment(
|
|
gatewayOrigin: String,
|
|
relayOrigin: String
|
|
) throws {
|
|
if pendingRemoveFailuresRemaining > 0 {
|
|
pendingRemoveFailuresRemaining -= 1
|
|
throw NSError(domain: "MemoryGrantStore", code: 3)
|
|
}
|
|
pending.removeAll {
|
|
$0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class RecordingAppAttest: BuzzDevAppAttesting {
|
|
var clientData: [Data] = []
|
|
var preparedAttestations: [BuzzDevAttestation] = []
|
|
var assertionKeyIds: [String] = []
|
|
|
|
func prepareAttestation() async throws -> BuzzDevAttestation {
|
|
let prepared = BuzzDevAttestation(
|
|
keyId: BuzzDevPushEnrollmentDriverTests.keyId,
|
|
attestation: BuzzDevPushEnrollmentDriverTests.attestation
|
|
)
|
|
preparedAttestations.append(prepared)
|
|
return prepared
|
|
}
|
|
|
|
func attestation(
|
|
_ prepared: BuzzDevAttestation,
|
|
clientData: Data
|
|
) async throws -> BuzzDevAttestation {
|
|
self.clientData.append(clientData)
|
|
return prepared
|
|
}
|
|
|
|
func assertion(keyId: String, clientData: Data) async throws -> String {
|
|
self.clientData.append(clientData)
|
|
assertionKeyIds.append(keyId)
|
|
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()
|
|
}
|
|
}
|