498f407585
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
383 lines
13 KiB
Swift
383 lines
13 KiB
Swift
// DeviceIntegrityTests.swift
|
|
// OSGKeyboardTests
|
|
//
|
|
// Hermetic DeviceCheck and App Attest registration/assertion tests.
|
|
|
|
import CryptoKit
|
|
@testable import OSGKeyboardHostSupport
|
|
import XCTest
|
|
|
|
final class DeviceIntegrityTests: XCTestCase {
|
|
func testAppleSignInEvidenceRegistersKeyAndSignsCanonicalPayload() async throws {
|
|
let attestationChallenge = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")!
|
|
let assertionChallenge = UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")!
|
|
let transport = QueueAccountTransport([
|
|
.init(
|
|
statusCode: 201,
|
|
body: challengeData(
|
|
id: attestationChallenge,
|
|
challenge: "AQID"
|
|
)
|
|
),
|
|
.init(statusCode: 204, body: Data()),
|
|
.init(
|
|
statusCode: 201,
|
|
body: challengeData(
|
|
id: assertionChallenge,
|
|
challenge: "BAUG"
|
|
)
|
|
)
|
|
])
|
|
let store = InMemoryAccountSecurityStore()
|
|
let client = AccountAPIClient(
|
|
baseURL: URL(string: "https://account.test")!,
|
|
transport: transport,
|
|
sessionVault: store
|
|
)
|
|
let appAttestState = FakeAppAttestState()
|
|
let coordinator = DeviceIntegrityCoordinator(
|
|
apiClient: client,
|
|
deviceCheck: FakeDeviceCheckProvider(
|
|
isSupported: true,
|
|
token: Data([0x10, 0x20])
|
|
),
|
|
appAttest: FakeAppAttestProvider(
|
|
isSupported: true,
|
|
state: appAttestState,
|
|
keyId: "key-id",
|
|
attestationObject: Data([0xAA, 0xBB]),
|
|
assertion: Data([0xCC])
|
|
),
|
|
keyStateStore: store
|
|
)
|
|
let credential = AppleSignInCredential(
|
|
identityToken: "identity-token",
|
|
authorizationCode: "authorization-code"
|
|
)
|
|
|
|
let evidence = try await coordinator.evidenceForAppleSignIn(
|
|
credential: credential,
|
|
rawNonce: "raw-nonce"
|
|
)
|
|
|
|
XCTAssertEqual(evidence.deviceCheckToken, "ECA=")
|
|
XCTAssertEqual(
|
|
evidence.appAttest,
|
|
AppAttestAssertion(
|
|
keyId: "key-id",
|
|
challengeId: assertionChallenge,
|
|
challenge: "BAUG",
|
|
assertion: "zA=="
|
|
)
|
|
)
|
|
let keyState = await store.keyState
|
|
XCTAssertEqual(keyState, AppAttestKeyState(keyId: "key-id", isRegistered: true))
|
|
let generatedKeyCount = await appAttestState.generatedKeyCount
|
|
XCTAssertEqual(generatedKeyCount, 1)
|
|
|
|
let attestationHashes = await appAttestState.attestationHashes
|
|
XCTAssertEqual(attestationHashes, [Data(SHA256.hash(data: Data([1, 2, 3])))])
|
|
|
|
let canonicalPayload = """
|
|
osg-app-attest-v1
|
|
purpose=apple-sign-in
|
|
challenge=BAUG
|
|
identity_token_sha256=OcwzHhEgHO3_IBV8hI8o_WTuAx0hgRrERJAbfcPbjvA
|
|
authorization_code_sha256=WVYUJ4163Fe7kuKPogOooY15egdoT9_3XLvyqW6_hXc
|
|
nonce_sha256=LF0QeTgFOiJ18CLBU8mnH2XuB3VLi8pUPul6DDzGaZA
|
|
|
|
"""
|
|
let assertionHashes = await appAttestState.assertionHashes
|
|
XCTAssertEqual(
|
|
assertionHashes,
|
|
[Data(SHA256.hash(data: Data(canonicalPayload.utf8)))]
|
|
)
|
|
|
|
let requests = await transport.requests
|
|
XCTAssertEqual(requests.map(\.url?.path), [
|
|
"/v1/integrity/challenges",
|
|
"/v1/integrity/attest",
|
|
"/v1/integrity/challenges"
|
|
])
|
|
let attestationBody = try XCTUnwrap(requests[1].httpBody)
|
|
let attestationJSON = try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: attestationBody) as? [String: Any]
|
|
)
|
|
XCTAssertEqual(attestationJSON["attestationObject"] as? String, "qrs=")
|
|
}
|
|
|
|
func testDeviceCheckRemainsUsableWhenAppAttestRecoveryFails() async throws {
|
|
let transport = QueueAccountTransport([
|
|
.init(
|
|
statusCode: 201,
|
|
body: challengeData(
|
|
id: UUID(uuidString: "dddddddd-dddd-dddd-dddd-dddddddddddd")!,
|
|
challenge: "AQID"
|
|
)
|
|
)
|
|
])
|
|
let store = InMemoryAccountSecurityStore(
|
|
keyState: AppAttestKeyState(keyId: "stale-key", isRegistered: true)
|
|
)
|
|
let client = AccountAPIClient(
|
|
baseURL: URL(string: "https://account.test")!,
|
|
transport: transport,
|
|
sessionVault: store
|
|
)
|
|
let coordinator = DeviceIntegrityCoordinator(
|
|
apiClient: client,
|
|
deviceCheck: FakeDeviceCheckProvider(
|
|
isSupported: true,
|
|
token: Data([0x10, 0x20])
|
|
),
|
|
appAttest: AlwaysFailingAppAttestProvider(),
|
|
keyStateStore: store
|
|
)
|
|
|
|
let evidence = try await coordinator.evidenceForAppleSignIn(
|
|
credential: AppleSignInCredential(
|
|
identityToken: "identity-token",
|
|
authorizationCode: "authorization-code"
|
|
),
|
|
rawNonce: "raw-nonce"
|
|
)
|
|
|
|
XCTAssertEqual(evidence.deviceCheckToken, "ECA=")
|
|
XCTAssertNil(evidence.appAttest)
|
|
}
|
|
|
|
func testInvalidAppAttestKeyIsReplacedAndRegisteredOnce() async throws {
|
|
let transport = QueueAccountTransport([
|
|
.init(
|
|
statusCode: 201,
|
|
body: challengeData(
|
|
id: UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")!,
|
|
challenge: "AQID"
|
|
)
|
|
),
|
|
.init(
|
|
statusCode: 201,
|
|
body: challengeData(
|
|
id: UUID(uuidString: "ffffffff-ffff-ffff-ffff-ffffffffffff")!,
|
|
challenge: "BAUG"
|
|
)
|
|
),
|
|
.init(statusCode: 204, body: Data()),
|
|
.init(
|
|
statusCode: 201,
|
|
body: challengeData(
|
|
id: UUID(uuidString: "12121212-1212-1212-1212-121212121212")!,
|
|
challenge: "BwgJ"
|
|
)
|
|
)
|
|
])
|
|
let store = InMemoryAccountSecurityStore(
|
|
keyState: AppAttestKeyState(keyId: "stale-key", isRegistered: true)
|
|
)
|
|
let client = AccountAPIClient(
|
|
baseURL: URL(string: "https://account.test")!,
|
|
transport: transport,
|
|
sessionVault: store
|
|
)
|
|
let appAttestState = RecoveringAppAttestState()
|
|
let coordinator = DeviceIntegrityCoordinator(
|
|
apiClient: client,
|
|
deviceCheck: FakeDeviceCheckProvider(isSupported: false, token: Data()),
|
|
appAttest: RecoveringAppAttestProvider(state: appAttestState),
|
|
keyStateStore: store
|
|
)
|
|
|
|
let evidence = try await coordinator.evidenceForAppleSignIn(
|
|
credential: AppleSignInCredential(
|
|
identityToken: "identity-token",
|
|
authorizationCode: "authorization-code"
|
|
),
|
|
rawNonce: "raw-nonce"
|
|
)
|
|
|
|
XCTAssertEqual(evidence.appAttest?.keyId, "fresh-key")
|
|
let keyState = await store.keyState
|
|
XCTAssertEqual(keyState, AppAttestKeyState(keyId: "fresh-key", isRegistered: true))
|
|
let clearCount = await appAttestState.failedAssertionCount
|
|
XCTAssertEqual(clearCount, 1)
|
|
}
|
|
|
|
func testUnregisteredKeyFromAnotherEnvironmentIsReplaced() async throws {
|
|
let transport = QueueAccountTransport([
|
|
.init(
|
|
statusCode: 201,
|
|
body: challengeData(
|
|
id: UUID(uuidString: "13131313-1313-1313-1313-131313131313")!,
|
|
challenge: "AQID"
|
|
)
|
|
),
|
|
.init(
|
|
statusCode: 201,
|
|
body: challengeData(
|
|
id: UUID(uuidString: "14141414-1414-1414-1414-141414141414")!,
|
|
challenge: "BAUG"
|
|
)
|
|
),
|
|
.init(statusCode: 204, body: Data()),
|
|
.init(
|
|
statusCode: 201,
|
|
body: challengeData(
|
|
id: UUID(uuidString: "15151515-1515-1515-1515-151515151515")!,
|
|
challenge: "BwgJ"
|
|
)
|
|
)
|
|
])
|
|
let store = InMemoryAccountSecurityStore(
|
|
keyState: AppAttestKeyState(keyId: "stale-key", isRegistered: false)
|
|
)
|
|
let client = AccountAPIClient(
|
|
baseURL: URL(string: "https://account.test")!,
|
|
transport: transport,
|
|
sessionVault: store
|
|
)
|
|
let appAttestState = RecoveringAppAttestState()
|
|
let coordinator = DeviceIntegrityCoordinator(
|
|
apiClient: client,
|
|
deviceCheck: FakeDeviceCheckProvider(isSupported: false, token: Data()),
|
|
appAttest: RecoveringAppAttestProvider(state: appAttestState),
|
|
keyStateStore: store
|
|
)
|
|
|
|
let evidence = try await coordinator.evidenceForAppleSignIn(
|
|
credential: AppleSignInCredential(
|
|
identityToken: "identity-token",
|
|
authorizationCode: "authorization-code"
|
|
),
|
|
rawNonce: "raw-nonce"
|
|
)
|
|
|
|
XCTAssertEqual(evidence.appAttest?.keyId, "fresh-key")
|
|
let keyState = await store.keyState
|
|
let failedAttestationCount = await appAttestState.failedAttestationCount
|
|
XCTAssertEqual(keyState, AppAttestKeyState(keyId: "fresh-key", isRegistered: true))
|
|
XCTAssertEqual(failedAttestationCount, 1)
|
|
}
|
|
|
|
func testChallengeAssertionSendsBase64URLClientDataHash() async throws {
|
|
let challenge = AppAttestChallenge(
|
|
challengeId: UUID(uuidString: "cccccccc-cccc-cccc-cccc-cccccccccccc")!,
|
|
challenge: "AQID",
|
|
expiresAtEpochSeconds: 4_000_000_000
|
|
)
|
|
let transport = QueueAccountTransport([
|
|
.init(statusCode: 200, body: Data(#"{"counter":7}"#.utf8))
|
|
])
|
|
let store = InMemoryAccountSecurityStore()
|
|
let client = AccountAPIClient(
|
|
baseURL: URL(string: "https://account.test")!,
|
|
transport: transport,
|
|
sessionVault: store
|
|
)
|
|
let hash = Data(SHA256.hash(data: Data([1, 2, 3])))
|
|
|
|
let counter = try await client.submitAssertion(
|
|
challenge: challenge,
|
|
keyId: "key-id",
|
|
assertion: Data([0x01, 0x02]),
|
|
clientDataHash: hash
|
|
)
|
|
|
|
XCTAssertEqual(counter, 7)
|
|
let requests = await transport.requests
|
|
let request = try XCTUnwrap(requests.single)
|
|
let body = try XCTUnwrap(request.httpBody)
|
|
let json = try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: body) as? [String: Any]
|
|
)
|
|
XCTAssertEqual(
|
|
json["clientDataHash"] as? String,
|
|
hash.base64URLEncodedString()
|
|
)
|
|
XCTAssertEqual(json["assertion"] as? String, "AQI=")
|
|
}
|
|
}
|
|
|
|
private enum TestIntegrityFailure: Error {
|
|
case unavailable
|
|
}
|
|
|
|
private struct AlwaysFailingAppAttestProvider: AppAttestProviding {
|
|
let isSupported = true
|
|
|
|
func generateKey() async throws -> String {
|
|
throw TestIntegrityFailure.unavailable
|
|
}
|
|
|
|
func attestKey(
|
|
_ keyId: String,
|
|
clientDataHash: Data
|
|
) async throws -> Data {
|
|
throw TestIntegrityFailure.unavailable
|
|
}
|
|
|
|
func generateAssertion(
|
|
_ keyId: String,
|
|
clientDataHash: Data
|
|
) async throws -> Data {
|
|
throw TestIntegrityFailure.unavailable
|
|
}
|
|
}
|
|
|
|
private actor RecoveringAppAttestState {
|
|
private(set) var failedAssertionCount = 0
|
|
private(set) var failedAttestationCount = 0
|
|
|
|
func recordFailedAssertion() {
|
|
failedAssertionCount += 1
|
|
}
|
|
|
|
func recordFailedAttestation() {
|
|
failedAttestationCount += 1
|
|
}
|
|
}
|
|
|
|
private struct RecoveringAppAttestProvider: AppAttestProviding {
|
|
let isSupported = true
|
|
let state: RecoveringAppAttestState
|
|
|
|
func generateKey() async throws -> String {
|
|
"fresh-key"
|
|
}
|
|
|
|
func attestKey(
|
|
_ keyId: String,
|
|
clientDataHash: Data
|
|
) async throws -> Data {
|
|
if keyId == "stale-key" {
|
|
await state.recordFailedAttestation()
|
|
throw TestIntegrityFailure.unavailable
|
|
}
|
|
return Data([0xaa, 0xbb])
|
|
}
|
|
|
|
func generateAssertion(
|
|
_ keyId: String,
|
|
clientDataHash: Data
|
|
) async throws -> Data {
|
|
if keyId == "stale-key" {
|
|
await state.recordFailedAssertion()
|
|
throw TestIntegrityFailure.unavailable
|
|
}
|
|
return Data([0x01, 0x02])
|
|
}
|
|
}
|
|
|
|
private func challengeData(id: UUID, challenge: String) -> Data {
|
|
Data(
|
|
"""
|
|
{"challengeId":"\(id.uuidString.lowercased())","challenge":"\(challenge)","expiresAtEpochSeconds":4000000000}
|
|
""".utf8
|
|
)
|
|
}
|
|
|
|
private extension Array {
|
|
var single: Element? {
|
|
count == 1 ? first : nil
|
|
}
|
|
}
|