mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
Make the push gateway origin configurable across the relay, gateway, mobile client, and deployment chart. This is a fresh deployment. The gateway has only run on personal development infrastructure, and push has no existing enabled users. Legacy migration, configured-to-unconfigured mobile transitions, and rolling upgrades from pre-launch gateway binaries are not supported. Populated pre-launch databases are rejected with an informative error before migrations run; their data is untouched. Mobile builds may omit the gateway URL, leaving push unavailable. Once push is enabled, subsequent builds must retain that configuration. ### Validation - All applicable CI checks pass at `5f546cfaf403f0386f146d213adfb4392f4ec29c`, including Rust, mobile/Swift, desktop/integration, security, image builds, and Helm checks. An unrelated agent cancellation test passed on retry. - PostgreSQL CI passed 384 tests, including the production-entry-point regression for refusing populated pre-launch databases before migrations, fresh initialization, and subsequent initialized deployments. - Focused local validation passed all 10 gateway PostgreSQL tests under the CI discovery filter and all 3 unconfigured-mobile tests. Repository-wide PostgreSQL discovery validation and the complete pre-push hooks passed. - Physical-device APNs/App Attest behavior and deployment to a live cluster were not verified in this work. --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Codex <noreply@openai.com>
1434 lines
51 KiB
Swift
1434 lines
51 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)
|
|
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.gatewayOrigin, Self.gatewayOrigin)
|
|
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(
|
|
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)))),
|
|
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 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","appProfile":"buzz-ios-dogfood","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","appProfile":"buzz-ios-dogfood","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)))),
|
|
appProfile: "buzz-ios-dogfood",
|
|
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)))),
|
|
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(
|
|
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)))),
|
|
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(
|
|
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)))),
|
|
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(
|
|
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)),
|
|
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(
|
|
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)),
|
|
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(
|
|
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)),
|
|
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(
|
|
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
|
|
&& $0.appProfile == record.appProfile
|
|
}
|
|
saved.append(record)
|
|
}
|
|
func pendingEnrollment(
|
|
gatewayOrigin: String,
|
|
relayOrigin: String,
|
|
appProfile: String
|
|
) throws -> BuzzPushPendingEnrollmentRecord? {
|
|
pending.first {
|
|
$0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin
|
|
&& $0.appProfile == appProfile
|
|
}
|
|
}
|
|
func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws {
|
|
pending.removeAll {
|
|
$0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin
|
|
&& $0.appProfile == record.appProfile
|
|
}
|
|
pending.append(record)
|
|
}
|
|
func removePendingEnrollment(
|
|
gatewayOrigin: String,
|
|
relayOrigin: String,
|
|
appProfile: String
|
|
) throws {
|
|
if pendingRemoveFailuresRemaining > 0 {
|
|
pendingRemoveFailuresRemaining -= 1
|
|
throw NSError(domain: "MemoryGrantStore", code: 3)
|
|
}
|
|
pending.removeAll {
|
|
$0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin
|
|
&& $0.appProfile == appProfile
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|