commit 0db18d26817aeb30740f74d66d08c53cac72c305
parent 20c4340e40eccfdf90aad7fb83015c86e430104c
Author: Michael Camilleri <[email protected]>
Date: Fri, 12 Jun 2026 08:30:10 +0900
Guard push secret generation and document the address capability boundary
generatePushSecret() in AccountPushCoordinator discarded the
SecRandomCopyBytes result, so on a (vanishingly unlikely) failure the
minted account push secret would be the base64 of 32 zero bytes — a
predictable HMAC key that would then converge durably across the
account's devices as a Decision record. The status is now checked with a
precondition, and the inline base64url encoding is replaced with the
existing Data.base64URLEncodedString() helper.
The address-as-capability boundary is now documented at both ends:
derived per-game push addresses are bearer capabilities, the worker's
publish path performs no ownership check, and authorization rests on the
secret staying inside the account's private CloudKit database. The
handlePublish comment also records why no worker-side relationship check
exists: addresses are opaque HMAC outputs and game membership lives only
in CloudKit, which the worker cannot see.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
2 files changed, 20 insertions(+), 5 deletions(-)
diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift
@@ -134,6 +134,14 @@ final class AccountPushCoordinator {
/// mint starts at `decisionBaseVersion`; a deliberate replacement goes
/// through `rotateAccountPushSecret`, which bumps the generation so it
/// supersedes the converged value.
+ ///
+ /// The derived addresses are bearer capabilities: the worker's publish path
+ /// delivers to any address an enrolled client names, with no notion of who
+ /// owns or participates in it. Authorization therefore rests entirely on
+ /// this secret staying inside the account's private CloudKit database. The
+ /// secret itself is never a credential the worker can check — protecting it
+ /// matters because leaking it lets anyone derive (and push to) every one of
+ /// the account's per-game addresses.
private func ensureAccountPushSecret(authorID: String) -> String {
let key = accountPushSecretDefaultsKey(authorID: authorID)
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
@@ -165,11 +173,12 @@ final class AccountPushCoordinator {
private static func generatePushSecret() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
- _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- return Data(bytes).base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
+ let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ // A failed mint would otherwise produce the base64 of 32 zero bytes —
+ // a predictable HMAC key that converges durably across the account's
+ // devices. Crashing is preferable to publishing that.
+ precondition(status == errSecSuccess, "SecRandomCopyBytes failed: \(status)")
+ return Data(bytes).base64URLEncodedString()
}
private func cacheAccountPushSecret(_ secret: String, version: Int64, authorID: String) {
diff --git a/Workers/push-worker.js b/Workers/push-worker.js
@@ -366,6 +366,12 @@ export class PushRegistry {
return new Response(null, { status: 204 });
}
+ // Addresses are bearer capabilities: any App Attest-enrolled client that
+ // names an address can publish to it. The worker has no notion of games,
+ // accounts, or participants, so there is no ownership check to make here —
+ // authorization rests on addresses being HMAC-derived from an account
+ // secret that never reaches the worker and is shared only between the
+ // account's own devices via the private CloudKit database.
async handlePublish(bodyText, auth) {
const body = await readJSONText(bodyText);
if (!body) return badRequest("Body must be JSON");