mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile/ios): NIP-PL installation lifecycle state machine + Keychain store
Implements the B2/B4 client-side lifecycle rules from the iOS cold review as a pure decision machine plus a Keychain persistence seam, all in BuzzPushKit and fully unit-tested without App Attest/network: - BuzzPushInstallationState: durable installation record with a two-phase pendingRotation intent and local APNs-token fingerprinting (SHA-256 of the lowercase-hex token), so change detection never retains the token. - BuzzPushLifecycle.onDeviceToken: rotate ONLY on a real token fingerprint change (iOS re-delivers the token every launch; unconditional rotate bumps the server epoch and kills every outstanding relay delegation); proactive re-enroll inside a 7-day renewal window before expires_at; surviving intents resume instead of desyncing. - BuzzPushLifecycle.applyRotate: persist-then-confirm epoch commit; transient retains the intent; invalid_attestation (zombie key after backup-restore) clears and re-enrolls; ambiguous not_authorized after a crash window adopts the pending epoch and escalates once before giving up — deterministic reconvergence without reading server state. - KeychainPushStateStore: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (NSE must read while locked; ThisDeviceOnly excludes the record from backup-restore, killing the zombie-installation class at the storage layer) with optional access group for the extension; versioned JSON envelope that fails closed to 'corrupt' instead of crashing. swift test: 32/32 green (10 transcript vectors + 22 lifecycle/store). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
parent
88be670406
commit
0438c0ef63
@@ -0,0 +1,74 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Durable client-side record of one NIP-PL push installation.
|
||||
///
|
||||
/// This is the state the cold review's B2/B4 findings are about: it must
|
||||
/// survive relaunch, must NOT survive backup-restore onto a new device
|
||||
/// (App Attest keys are device-bound, so a restored copy is a zombie), and
|
||||
/// must stay epoch-consistent with the gateway across a crash between
|
||||
/// `POST /v1/installations/endpoint` succeeding and the local commit.
|
||||
///
|
||||
/// The two-phase `pendingRotation` field is the crash-window answer: the
|
||||
/// intent is persisted *before* the rotate request is sent, and cleared only
|
||||
/// after the outcome is known. `BuzzPushLifecycle` turns a surviving intent
|
||||
/// back into a resumable decision.
|
||||
public struct BuzzPushInstallationState: Codable, Equatable {
|
||||
/// Two-phase commit record for an in-flight endpoint rotation.
|
||||
public struct PendingRotation: Codable, Equatable {
|
||||
/// `endpoint_epoch + 1` at the time the intent was written.
|
||||
public var newEndpointEpoch: Int64
|
||||
/// Fingerprint of the APNs token the rotation is moving to.
|
||||
public var newEndpointFingerprint: String
|
||||
|
||||
public init(newEndpointEpoch: Int64, newEndpointFingerprint: String) {
|
||||
self.newEndpointEpoch = newEndpointEpoch
|
||||
self.newEndpointFingerprint = newEndpointFingerprint
|
||||
}
|
||||
}
|
||||
|
||||
/// Gateway-issued installation handle (`installation_handle`).
|
||||
public var installationHandle: UUID
|
||||
/// App Attest key identifier (standard base64), device-bound.
|
||||
public var keyId: String
|
||||
/// Registered profile, e.g. `buzz-ios-production`.
|
||||
public var appProfile: String
|
||||
/// Current committed endpoint epoch (matches gateway on the happy path).
|
||||
public var endpointEpoch: Int64
|
||||
/// SHA-256 hex of the lowercase-hex APNs token currently enrolled.
|
||||
/// Stored instead of the raw token so change detection never requires
|
||||
/// retaining the token itself.
|
||||
public var endpointFingerprint: String
|
||||
/// Installation expiry (unix seconds), as returned by enrollment.
|
||||
public var expiresAt: Int64
|
||||
/// In-flight rotation intent, if a rotate was started but not confirmed.
|
||||
public var pendingRotation: PendingRotation?
|
||||
|
||||
public init(
|
||||
installationHandle: UUID,
|
||||
keyId: String,
|
||||
appProfile: String,
|
||||
endpointEpoch: Int64,
|
||||
endpointFingerprint: String,
|
||||
expiresAt: Int64,
|
||||
pendingRotation: PendingRotation? = nil
|
||||
) {
|
||||
self.installationHandle = installationHandle
|
||||
self.keyId = keyId
|
||||
self.appProfile = appProfile
|
||||
self.endpointEpoch = endpointEpoch
|
||||
self.endpointFingerprint = endpointFingerprint
|
||||
self.expiresAt = expiresAt
|
||||
self.pendingRotation = pendingRotation
|
||||
}
|
||||
|
||||
/// Canonical fingerprint of an APNs token: SHA-256 hex over the ASCII
|
||||
/// bytes of the lowercase-hex token string (the same representation the
|
||||
/// transcripts carry). Purely local — this is NOT the gateway's
|
||||
/// `(app_profile, SHA-256(token))` uniqueness fingerprint.
|
||||
public static func fingerprint(ofEndpoint endpointHex: String) -> String {
|
||||
SHA256.hash(data: Data(endpointHex.lowercased().utf8))
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure NIP-PL client lifecycle decision machine.
|
||||
///
|
||||
/// This encodes the B2/B4 rules from the iOS lifecycle cold review as a
|
||||
/// side-effect-free function of (persisted state, observed APNs token,
|
||||
/// clock), so every rule is unit-testable without App Attest, Keychain, or
|
||||
/// network:
|
||||
///
|
||||
/// - **Rotate only on real token change.** iOS re-delivers the device token
|
||||
/// on every launch; an unconditional rotate bumps the server epoch each
|
||||
/// launch and invalidates every outstanding relay delegation. The machine
|
||||
/// compares fingerprints and answers `.noop` for a re-delivered token.
|
||||
/// - **Persist-then-confirm epoch.** A rotation is two-phase: the caller
|
||||
/// persists the `pendingRotation` intent *before* sending
|
||||
/// `POST /v1/installations/endpoint`, and commits/clears it only on a known
|
||||
/// outcome. A crash in between leaves the intent in place; the machine
|
||||
/// answers `.resumeRotation` on the next launch instead of desyncing.
|
||||
/// - **Zombie-key recovery.** After backup-restore to a new device the
|
||||
/// restored state names an App Attest key that no longer exists (keys are
|
||||
/// device-bound), so every assertion fails. `apply(outcome:)` maps
|
||||
/// attestation-level rejection to `.clearAndReenroll` instead of retrying
|
||||
/// forever.
|
||||
/// - **Renewal.** Installations expire (`expires_at`); an expired or
|
||||
/// near-expiry installation re-enrolls instead of silently failing rotate
|
||||
/// and delegate calls with a stale handle.
|
||||
public enum BuzzPushLifecycle {
|
||||
/// How far before `expires_at` the client proactively re-enrolls.
|
||||
/// 7 days against the 90-day default lifetime: early enough to cover
|
||||
/// long app dormancy between launches, tiny relative to the lease.
|
||||
public static let renewalLeadTimeSeconds: Int64 = 7 * 24 * 3600
|
||||
|
||||
// MARK: Launch decision
|
||||
|
||||
/// What to do when APNs (re)delivers a device token.
|
||||
public enum TokenDecision: Equatable {
|
||||
/// No persisted installation (or it must be discarded): run full
|
||||
/// enrollment for the given token fingerprint.
|
||||
case enroll
|
||||
/// Installation valid and the token is unchanged: do nothing.
|
||||
case noop
|
||||
/// Installation expired or inside the renewal window: re-enroll
|
||||
/// (fresh attest key + handle), then discard the old state.
|
||||
case reenroll
|
||||
/// Token genuinely changed: persist `stateWithIntent` FIRST, then
|
||||
/// send rotate with `endpoint_epoch = stateWithIntent.endpointEpoch`
|
||||
/// and `new_endpoint_epoch = pending.newEndpointEpoch`.
|
||||
case beginRotation(stateWithIntent: BuzzPushInstallationState)
|
||||
/// A previous rotation intent survived (crash window): re-send the
|
||||
/// same rotate. The intent already pins the target epoch, so this is
|
||||
/// idempotent from the client's perspective; `apply(outcome:)`
|
||||
/// resolves whether the server had already committed.
|
||||
case resumeRotation(state: BuzzPushInstallationState)
|
||||
}
|
||||
|
||||
/// Decide the launch/token action. `tokenFingerprint` is
|
||||
/// `BuzzPushInstallationState.fingerprint(ofEndpoint:)` of the freshly
|
||||
/// delivered token; `now` is unix seconds.
|
||||
public static func onDeviceToken(
|
||||
state: BuzzPushInstallationState?,
|
||||
tokenFingerprint: String,
|
||||
now: Int64
|
||||
) -> TokenDecision {
|
||||
guard let state else { return .enroll }
|
||||
if now >= state.expiresAt - renewalLeadTimeSeconds {
|
||||
return .reenroll
|
||||
}
|
||||
if let pending = state.pendingRotation {
|
||||
// A surviving intent wins over fingerprint comparison: the local
|
||||
// committed fingerprint may or may not match the server, and the
|
||||
// intent records where we were headed.
|
||||
if pending.newEndpointFingerprint == tokenFingerprint {
|
||||
return .resumeRotation(state: state)
|
||||
}
|
||||
// Token moved again while a rotation was in flight. Re-point the
|
||||
// intent at the new token but keep the same target epoch — the
|
||||
// old intent never confirmed, so the epoch step is still ours.
|
||||
var next = state
|
||||
next.pendingRotation = .init(
|
||||
newEndpointEpoch: pending.newEndpointEpoch,
|
||||
newEndpointFingerprint: tokenFingerprint
|
||||
)
|
||||
return .beginRotation(stateWithIntent: next)
|
||||
}
|
||||
if state.endpointFingerprint == tokenFingerprint {
|
||||
return .noop
|
||||
}
|
||||
var next = state
|
||||
next.pendingRotation = .init(
|
||||
newEndpointEpoch: state.endpointEpoch + 1,
|
||||
newEndpointFingerprint: tokenFingerprint
|
||||
)
|
||||
return .beginRotation(stateWithIntent: next)
|
||||
}
|
||||
|
||||
// MARK: Rotation outcome
|
||||
|
||||
/// Gateway response classification for a rotate attempt. The caller maps
|
||||
/// HTTP status/error codes to this enum.
|
||||
public enum RotateOutcome: Equatable {
|
||||
/// `200 {"status":"rotated"}`.
|
||||
case rotated
|
||||
/// `404 not_authorized` — epoch mismatch, consumed challenge, or
|
||||
/// missing/expired installation. Indistinguishable by design.
|
||||
case notAuthorized
|
||||
/// `401 invalid_attestation` — assertion failed to verify. With a
|
||||
/// device-bound key this is the zombie-key (backup-restore) or
|
||||
/// tampered-state signature.
|
||||
case invalidAttestation
|
||||
/// Transport failure / 5xx / timeout: outcome unknown.
|
||||
case transient
|
||||
}
|
||||
|
||||
/// What the caller must do after a rotate attempt resolves.
|
||||
public enum RotateResolution: Equatable {
|
||||
/// Persist this state (intent committed or advanced).
|
||||
case persist(BuzzPushInstallationState)
|
||||
/// Keep the persisted intent as-is and retry later (with backoff).
|
||||
case retainIntent
|
||||
/// Local state is unrecoverable: delete it and run full enrollment.
|
||||
case clearAndReenroll
|
||||
}
|
||||
|
||||
/// Resolve a rotate attempt that was sent with
|
||||
/// `endpoint_epoch = state.endpointEpoch` and
|
||||
/// `new_endpoint_epoch = state.pendingRotation!.newEndpointEpoch`.
|
||||
///
|
||||
/// `notAuthorized` on a *resumed* intent is the ambiguous crash-window
|
||||
/// case: the server may have committed the previous attempt (so our old
|
||||
/// epoch no longer matches). We cannot read the server epoch back, so
|
||||
/// the deterministic reconvergence is: adopt the pending epoch as
|
||||
/// committed, then immediately begin a fresh rotation FROM it — if the
|
||||
/// server had committed, this succeeds and both sides converge; if the
|
||||
/// installation is actually gone, the next attempt maps to
|
||||
/// `.clearAndReenroll` via `escalated`.
|
||||
public static func applyRotate(
|
||||
state: BuzzPushInstallationState,
|
||||
outcome: RotateOutcome,
|
||||
escalated: Bool = false
|
||||
) -> RotateResolution {
|
||||
guard let pending = state.pendingRotation else {
|
||||
// No intent recorded: nothing to commit; treat as corrupt state.
|
||||
return .clearAndReenroll
|
||||
}
|
||||
switch outcome {
|
||||
case .rotated:
|
||||
var next = state
|
||||
next.endpointEpoch = pending.newEndpointEpoch
|
||||
next.endpointFingerprint = pending.newEndpointFingerprint
|
||||
next.pendingRotation = nil
|
||||
return .persist(next)
|
||||
case .transient:
|
||||
return .retainIntent
|
||||
case .invalidAttestation:
|
||||
// Assertion itself failed: the attest key no longer signs for
|
||||
// this state (restored backup / wiped key). Retrying is futile.
|
||||
return .clearAndReenroll
|
||||
case .notAuthorized:
|
||||
if escalated {
|
||||
// Already adopted-and-retried once; the installation is gone
|
||||
// or expired server-side.
|
||||
return .clearAndReenroll
|
||||
}
|
||||
// Adopt the pending epoch and step the intent forward one epoch;
|
||||
// the caller persists this and sends one escalated rotate.
|
||||
var next = state
|
||||
next.endpointEpoch = pending.newEndpointEpoch
|
||||
next.endpointFingerprint = pending.newEndpointFingerprint
|
||||
next.pendingRotation = .init(
|
||||
newEndpointEpoch: pending.newEndpointEpoch + 1,
|
||||
newEndpointFingerprint: pending.newEndpointFingerprint
|
||||
)
|
||||
return .persist(next)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Enrollment outcome
|
||||
|
||||
/// What the caller must do after an enrollment attempt resolves.
|
||||
public enum EnrollResolution: Equatable {
|
||||
/// Persist this freshly enrolled state (epoch is always 1).
|
||||
case persist(BuzzPushInstallationState)
|
||||
/// Retry later with backoff; keep whatever state existed.
|
||||
case retryLater
|
||||
/// Give up on this attest key and generate a new one next attempt.
|
||||
case discardKeyAndRetry
|
||||
}
|
||||
|
||||
/// Resolve an enrollment attempt. `keyId`/`appProfile` are what was
|
||||
/// attested; the handle/epoch/expiry come from the `201` response.
|
||||
public static func applyEnroll(
|
||||
response: (installationHandle: UUID, endpointEpoch: Int64, expiresAt: Int64)?,
|
||||
keyId: String,
|
||||
appProfile: String,
|
||||
endpointFingerprint: String,
|
||||
invalidAttestation: Bool
|
||||
) -> EnrollResolution {
|
||||
if let r = response {
|
||||
return .persist(
|
||||
BuzzPushInstallationState(
|
||||
installationHandle: r.installationHandle,
|
||||
keyId: keyId,
|
||||
appProfile: appProfile,
|
||||
endpointEpoch: r.endpointEpoch,
|
||||
endpointFingerprint: endpointFingerprint,
|
||||
expiresAt: r.expiresAt
|
||||
)
|
||||
)
|
||||
}
|
||||
// A rejected attestation on ENROLL means the key/attestation object
|
||||
// itself was refused (or the key was already enrolled) — minting a
|
||||
// fresh key is the only forward path. Transient failures retry with
|
||||
// the same key.
|
||||
return invalidAttestation ? .discardKeyAndRetry : .retryLater
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
#if canImport(Security)
|
||||
import Security
|
||||
#endif
|
||||
|
||||
/// Persistence seam for `BuzzPushInstallationState`.
|
||||
///
|
||||
/// The production implementation is `KeychainPushStateStore`; tests use
|
||||
/// `InMemoryPushStateStore`. Kept deliberately tiny: load / save / clear.
|
||||
public protocol BuzzPushStateStore {
|
||||
func load() throws -> BuzzPushInstallationState?
|
||||
func save(_ state: BuzzPushInstallationState) throws
|
||||
func clear() throws
|
||||
}
|
||||
|
||||
public enum BuzzPushStateStoreError: Error, Equatable {
|
||||
/// Underlying Keychain call failed with the given OSStatus.
|
||||
case keychain(OSStatus)
|
||||
/// Persisted bytes did not decode; the caller should treat this as
|
||||
/// no-state (clear + re-enroll) rather than crash.
|
||||
case corrupt
|
||||
}
|
||||
|
||||
/// JSON codec shared by every store. Versioned envelope so a future schema
|
||||
/// change can migrate instead of tripping `corrupt`.
|
||||
enum BuzzPushStateCodec {
|
||||
struct Envelope: Codable {
|
||||
var version: Int
|
||||
var state: BuzzPushInstallationState
|
||||
}
|
||||
|
||||
static let currentVersion = 1
|
||||
|
||||
static func encode(_ state: BuzzPushInstallationState) throws -> Data {
|
||||
try JSONEncoder().encode(Envelope(version: currentVersion, state: state))
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) throws -> BuzzPushInstallationState {
|
||||
guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data),
|
||||
envelope.version == currentVersion
|
||||
else {
|
||||
throw BuzzPushStateStoreError.corrupt
|
||||
}
|
||||
return envelope.state
|
||||
}
|
||||
}
|
||||
|
||||
/// Test double / non-Darwin fallback.
|
||||
public final class InMemoryPushStateStore: BuzzPushStateStore {
|
||||
private var data: Data?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func load() throws -> BuzzPushInstallationState? {
|
||||
try data.map(BuzzPushStateCodec.decode)
|
||||
}
|
||||
|
||||
public func save(_ state: BuzzPushInstallationState) throws {
|
||||
data = try BuzzPushStateCodec.encode(state)
|
||||
}
|
||||
|
||||
public func clear() throws {
|
||||
data = nil
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(Security)
|
||||
/// Keychain-backed store (kSecClassGenericPassword).
|
||||
///
|
||||
/// Attribute choices are load-bearing (cold review B2):
|
||||
///
|
||||
/// - `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`:
|
||||
/// * *AfterFirstUnlock* — the Notification Service Extension and background
|
||||
/// launches must read this while the device is locked (post-first-unlock),
|
||||
/// so `WhenUnlocked` is wrong.
|
||||
/// * *ThisDeviceOnly* — App Attest keys are device-bound. If this record
|
||||
/// migrated through backup/restore to a new device it would name a key
|
||||
/// that can never sign again (the zombie-installation failure). Excluding
|
||||
/// it from device transfers kills that class at the storage layer;
|
||||
/// `BuzzPushLifecycle`'s `invalidAttestation → clearAndReenroll` remains
|
||||
/// as defense in depth.
|
||||
/// - `kSecAttrAccessGroup` (optional) shares the item with the NSE via an
|
||||
/// App Group / keychain access group.
|
||||
///
|
||||
/// NOTE: this state is *authorization bookkeeping*, not key material — the
|
||||
/// actual private key lives in the Secure Enclave under App Attest.
|
||||
public final class KeychainPushStateStore: BuzzPushStateStore {
|
||||
private let service: String
|
||||
private let account: String
|
||||
private let accessGroup: String?
|
||||
|
||||
public init(
|
||||
service: String = "xyz.buzz.push.installation",
|
||||
account: String = "nip-pl-state",
|
||||
accessGroup: String? = nil
|
||||
) {
|
||||
self.service = service
|
||||
self.account = account
|
||||
self.accessGroup = accessGroup
|
||||
}
|
||||
|
||||
private func baseQuery() -> [String: Any] {
|
||||
var q: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
if let accessGroup {
|
||||
q[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
public func load() throws -> BuzzPushInstallationState? {
|
||||
var q = baseQuery()
|
||||
q[kSecReturnData as String] = true
|
||||
q[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var out: CFTypeRef?
|
||||
let status = SecItemCopyMatching(q as CFDictionary, &out)
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
guard let data = out as? Data else { throw BuzzPushStateStoreError.corrupt }
|
||||
return try BuzzPushStateCodec.decode(data)
|
||||
case errSecItemNotFound:
|
||||
return nil
|
||||
default:
|
||||
throw BuzzPushStateStoreError.keychain(status)
|
||||
}
|
||||
}
|
||||
|
||||
public func save(_ state: BuzzPushInstallationState) throws {
|
||||
let data = try BuzzPushStateCodec.encode(state)
|
||||
var add = baseQuery()
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let status = SecItemAdd(add as CFDictionary, nil)
|
||||
if status == errSecDuplicateItem {
|
||||
let update: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String:
|
||||
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
let updateStatus = SecItemUpdate(baseQuery() as CFDictionary, update as CFDictionary)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw BuzzPushStateStoreError.keychain(updateStatus)
|
||||
}
|
||||
} else if status != errSecSuccess {
|
||||
throw BuzzPushStateStoreError.keychain(status)
|
||||
}
|
||||
}
|
||||
|
||||
public func clear() throws {
|
||||
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw BuzzPushStateStoreError.keychain(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,282 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@testable import BuzzPushKit
|
||||
|
||||
/// Unit tests for the pure lifecycle decision machine. Each test names the
|
||||
/// cold-review finding (B2/B4) it pins.
|
||||
final class BuzzPushLifecycleTests: XCTestCase {
|
||||
// MARK: Fixtures
|
||||
|
||||
static let handle = UUID(uuidString: "22222222-2222-4222-8222-222222222222")!
|
||||
static let tokenA = BuzzPushInstallationState.fingerprint(
|
||||
ofEndpoint: String(repeating: "01", count: 32))
|
||||
static let tokenB = BuzzPushInstallationState.fingerprint(
|
||||
ofEndpoint: String(repeating: "02", count: 32))
|
||||
static let tokenC = BuzzPushInstallationState.fingerprint(
|
||||
ofEndpoint: String(repeating: "03", count: 32))
|
||||
static let now: Int64 = 1_752_620_000
|
||||
|
||||
func freshState(
|
||||
epoch: Int64 = 3,
|
||||
fingerprint: String = tokenA,
|
||||
expiresAt: Int64 = now + 60 * 24 * 3600,
|
||||
pending: BuzzPushInstallationState.PendingRotation? = nil
|
||||
) -> BuzzPushInstallationState {
|
||||
BuzzPushInstallationState(
|
||||
installationHandle: Self.handle,
|
||||
keyId: "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=",
|
||||
appProfile: "buzz-ios-production",
|
||||
endpointEpoch: epoch,
|
||||
endpointFingerprint: fingerprint,
|
||||
expiresAt: expiresAt,
|
||||
pendingRotation: pending
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Token fingerprinting
|
||||
|
||||
func testFingerprintIsCaseInsensitiveOverHex() {
|
||||
XCTAssertEqual(
|
||||
BuzzPushInstallationState.fingerprint(ofEndpoint: "0A0B0C"),
|
||||
BuzzPushInstallationState.fingerprint(ofEndpoint: "0a0b0c")
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: B4 — rotate only on real token change
|
||||
|
||||
func testNoStateEnrolls() {
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.onDeviceToken(state: nil, tokenFingerprint: Self.tokenA, now: Self.now),
|
||||
.enroll
|
||||
)
|
||||
}
|
||||
|
||||
func testRedeliveredTokenIsNoop() {
|
||||
// iOS re-delivers the same token every launch; this MUST NOT rotate
|
||||
// (B4: unconditional rotate invalidates all delegations per launch).
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.onDeviceToken(
|
||||
state: freshState(), tokenFingerprint: Self.tokenA, now: Self.now),
|
||||
.noop
|
||||
)
|
||||
}
|
||||
|
||||
func testChangedTokenBeginsRotationWithPersistedIntent() {
|
||||
let decision = BuzzPushLifecycle.onDeviceToken(
|
||||
state: freshState(), tokenFingerprint: Self.tokenB, now: Self.now)
|
||||
guard case let .beginRotation(next) = decision else {
|
||||
return XCTFail("expected beginRotation, got \(decision)")
|
||||
}
|
||||
// Committed fields untouched until the gateway confirms.
|
||||
XCTAssertEqual(next.endpointEpoch, 3)
|
||||
XCTAssertEqual(next.endpointFingerprint, Self.tokenA)
|
||||
XCTAssertEqual(next.pendingRotation,
|
||||
.init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB))
|
||||
}
|
||||
|
||||
// MARK: B4 — renewal window
|
||||
|
||||
func testExpiredInstallationReenrolls() {
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.onDeviceToken(
|
||||
state: freshState(expiresAt: Self.now - 1),
|
||||
tokenFingerprint: Self.tokenA, now: Self.now),
|
||||
.reenroll
|
||||
)
|
||||
}
|
||||
|
||||
func testInsideRenewalLeadTimeReenrolls() {
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.onDeviceToken(
|
||||
state: freshState(expiresAt: Self.now + BuzzPushLifecycle.renewalLeadTimeSeconds - 1),
|
||||
tokenFingerprint: Self.tokenA, now: Self.now),
|
||||
.reenroll
|
||||
)
|
||||
}
|
||||
|
||||
func testOutsideRenewalLeadTimeDoesNotReenroll() {
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.onDeviceToken(
|
||||
state: freshState(expiresAt: Self.now + BuzzPushLifecycle.renewalLeadTimeSeconds + 1),
|
||||
tokenFingerprint: Self.tokenA, now: Self.now),
|
||||
.noop
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: B2 — crash window (persist-then-confirm)
|
||||
|
||||
func testSurvivingIntentResumesRotation() {
|
||||
let state = freshState(
|
||||
pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB))
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.onDeviceToken(
|
||||
state: state, tokenFingerprint: Self.tokenB, now: Self.now),
|
||||
.resumeRotation(state: state)
|
||||
)
|
||||
}
|
||||
|
||||
func testTokenMovedAgainDuringInFlightRotationKeepsTargetEpoch() {
|
||||
let state = freshState(
|
||||
pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB))
|
||||
let decision = BuzzPushLifecycle.onDeviceToken(
|
||||
state: state, tokenFingerprint: Self.tokenC, now: Self.now)
|
||||
guard case let .beginRotation(next) = decision else {
|
||||
return XCTFail("expected beginRotation, got \(decision)")
|
||||
}
|
||||
// The unconfirmed epoch step is reused; only the destination token moves.
|
||||
XCTAssertEqual(next.pendingRotation,
|
||||
.init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenC))
|
||||
}
|
||||
|
||||
func testSurvivingIntentMatchingCommittedFingerprintStillResumes() {
|
||||
// Crash AFTER server commit but BEFORE local commit, then the token
|
||||
// reverts (or never actually changed): the intent must still resolve
|
||||
// through the rotate path, never be dropped on the floor.
|
||||
let state = freshState(
|
||||
fingerprint: Self.tokenA,
|
||||
pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenA))
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.onDeviceToken(
|
||||
state: state, tokenFingerprint: Self.tokenA, now: Self.now),
|
||||
.resumeRotation(state: state)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Rotate outcomes
|
||||
|
||||
func testRotatedCommitsEpochAndClearsIntent() {
|
||||
let state = freshState(
|
||||
pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB))
|
||||
let resolution = BuzzPushLifecycle.applyRotate(state: state, outcome: .rotated)
|
||||
guard case let .persist(next) = resolution else {
|
||||
return XCTFail("expected persist, got \(resolution)")
|
||||
}
|
||||
XCTAssertEqual(next.endpointEpoch, 4)
|
||||
XCTAssertEqual(next.endpointFingerprint, Self.tokenB)
|
||||
XCTAssertNil(next.pendingRotation)
|
||||
}
|
||||
|
||||
func testTransientRetainsIntent() {
|
||||
let state = freshState(
|
||||
pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB))
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.applyRotate(state: state, outcome: .transient),
|
||||
.retainIntent
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: B2 — zombie key (backup-restore)
|
||||
|
||||
func testInvalidAttestationClearsAndReenrolls() {
|
||||
let state = freshState(
|
||||
pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB))
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.applyRotate(state: state, outcome: .invalidAttestation),
|
||||
.clearAndReenroll
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: B2 — epoch desync reconvergence
|
||||
|
||||
func testNotAuthorizedAdoptsPendingEpochAndEscalates() {
|
||||
// Ambiguous crash window: the server may have committed epoch 4.
|
||||
// First notAuthorized adopts 4 and points a fresh intent at 5.
|
||||
let state = freshState(
|
||||
pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB))
|
||||
let resolution = BuzzPushLifecycle.applyRotate(state: state, outcome: .notAuthorized)
|
||||
guard case let .persist(next) = resolution else {
|
||||
return XCTFail("expected persist, got \(resolution)")
|
||||
}
|
||||
XCTAssertEqual(next.endpointEpoch, 4)
|
||||
XCTAssertEqual(next.endpointFingerprint, Self.tokenB)
|
||||
XCTAssertEqual(next.pendingRotation,
|
||||
.init(newEndpointEpoch: 5, newEndpointFingerprint: Self.tokenB))
|
||||
}
|
||||
|
||||
func testEscalatedNotAuthorizedClearsAndReenrolls() {
|
||||
let state = freshState(
|
||||
epoch: 4, fingerprint: Self.tokenB,
|
||||
pending: .init(newEndpointEpoch: 5, newEndpointFingerprint: Self.tokenB))
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.applyRotate(state: state, outcome: .notAuthorized, escalated: true),
|
||||
.clearAndReenroll
|
||||
)
|
||||
}
|
||||
|
||||
func testRotateWithoutIntentIsCorruptState() {
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.applyRotate(state: freshState(), outcome: .rotated),
|
||||
.clearAndReenroll
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Enrollment outcomes
|
||||
|
||||
func testEnrollSuccessPersistsEpochOneState() {
|
||||
let resolution = BuzzPushLifecycle.applyEnroll(
|
||||
response: (Self.handle, 1, Self.now + 90 * 24 * 3600),
|
||||
keyId: "key",
|
||||
appProfile: "buzz-ios-production",
|
||||
endpointFingerprint: Self.tokenA,
|
||||
invalidAttestation: false
|
||||
)
|
||||
guard case let .persist(state) = resolution else {
|
||||
return XCTFail("expected persist, got \(resolution)")
|
||||
}
|
||||
XCTAssertEqual(state.endpointEpoch, 1)
|
||||
XCTAssertEqual(state.endpointFingerprint, Self.tokenA)
|
||||
XCTAssertNil(state.pendingRotation)
|
||||
}
|
||||
|
||||
func testEnrollInvalidAttestationDiscardsKey() {
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.applyEnroll(
|
||||
response: nil, keyId: "key", appProfile: "buzz-ios-production",
|
||||
endpointFingerprint: Self.tokenA, invalidAttestation: true),
|
||||
.discardKeyAndRetry
|
||||
)
|
||||
}
|
||||
|
||||
func testEnrollTransientRetries() {
|
||||
XCTAssertEqual(
|
||||
BuzzPushLifecycle.applyEnroll(
|
||||
response: nil, keyId: "key", appProfile: "buzz-ios-production",
|
||||
endpointFingerprint: Self.tokenA, invalidAttestation: false),
|
||||
.retryLater
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Store round-trip (codec + in-memory store)
|
||||
|
||||
func testStateStoreRoundTrip() throws {
|
||||
let store = InMemoryPushStateStore()
|
||||
XCTAssertNil(try store.load())
|
||||
let state = freshState(
|
||||
pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB))
|
||||
try store.save(state)
|
||||
XCTAssertEqual(try store.load(), state)
|
||||
try store.clear()
|
||||
XCTAssertNil(try store.load())
|
||||
}
|
||||
|
||||
func testCorruptEnvelopeThrowsCorrupt() {
|
||||
XCTAssertThrowsError(try BuzzPushStateCodec.decode(Data("{}".utf8))) { error in
|
||||
XCTAssertEqual(error as? BuzzPushStateStoreError, .corrupt)
|
||||
}
|
||||
XCTAssertThrowsError(try BuzzPushStateCodec.decode(Data("not json".utf8))) { error in
|
||||
XCTAssertEqual(error as? BuzzPushStateStoreError, .corrupt)
|
||||
}
|
||||
}
|
||||
|
||||
func testFutureVersionEnvelopeIsCorruptNotCrash() throws {
|
||||
let state = freshState()
|
||||
var object = try JSONSerialization.jsonObject(
|
||||
with: BuzzPushStateCodec.encode(state)) as! [String: Any]
|
||||
object["version"] = 999
|
||||
let data = try JSONSerialization.data(withJSONObject: object)
|
||||
XCTAssertThrowsError(try BuzzPushStateCodec.decode(data)) { error in
|
||||
XCTAssertEqual(error as? BuzzPushStateStoreError, .corrupt)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user