mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile/ios): Swift canonical NIP-PL App Attest transcript encoder
BuzzPushKit SwiftPM package with a hand-rolled ordered-compact-JSON transcript encoder for all five App Attest domains (enroll / delegate / rotate-endpoint / revoke-delegation / revoke-installation), verified byte-for-byte + SHA-256 against the gateway-generated known-answer vectors in crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json. JSONSerialization is deliberately not used anywhere near these bytes: it escapes '/' as '\/' and does not guarantee member order, either of which silently strands every iOS client with 401 invalid_attestation. Non-ASCII input fails closed per the NIP-PL ASCII profile. The 'audience' member is a fixed NIP-PL protocol constant (https://push.buzz.xyz/v1/...) hardcoded by the gateway for cross-route domain separation — clients must never derive it from a discovered gateway base URL or relay host. 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
a5098e990a
commit
88be670406
@@ -0,0 +1,14 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "BuzzPushKit",
|
||||
platforms: [.iOS(.v15), .macOS(.v12)],
|
||||
products: [
|
||||
.library(name: "BuzzPushKit", targets: ["BuzzPushKit"])
|
||||
],
|
||||
targets: [
|
||||
.target(name: "BuzzPushKit"),
|
||||
.testTarget(name: "BuzzPushKitTests", dependencies: ["BuzzPushKit"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors thrown by the canonical transcript encoder.
|
||||
public enum BuzzPushTranscriptError: Error, Equatable {
|
||||
/// A string field contained non-ASCII scalars. NIP-PL admits only ASCII
|
||||
/// authority-bearing strings; rather than guess at UTF-8-vs-escaping
|
||||
/// behavior we fail closed.
|
||||
case nonASCIIInput(field: String)
|
||||
}
|
||||
|
||||
/// Canonical NIP-PL App Attest transcript encoder.
|
||||
///
|
||||
/// NIP-PL ("Exact App Attest transcript construction") pins the exact bytes
|
||||
/// every App Attest operation signs:
|
||||
///
|
||||
/// <domain> + "\n" + <compact ordered JSON object>
|
||||
///
|
||||
/// The JSON object has no insignificant whitespace, members appear in a fixed
|
||||
/// per-route order, integers use shortest decimal notation, and strings use
|
||||
/// minimal JSON escaping (quotation mark, reverse solidus, U+0000..U+001F).
|
||||
/// The gateway builds the same bytes with serde_json and compares hashes, so
|
||||
/// any byte difference is a silent `401 invalid_attestation`. This encoder is
|
||||
/// hand-rolled for that reason: `JSONSerialization` escapes `/` as `\/` and
|
||||
/// does not guarantee member order, so it must never be used for transcripts.
|
||||
///
|
||||
/// Ground truth: `crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json`,
|
||||
/// generated and asserted by the gateway's own encoder. The tests in this
|
||||
/// package replay those vectors byte-for-byte.
|
||||
///
|
||||
/// The `audience` member of each transcript is a **fixed protocol constant**
|
||||
/// defined by NIP-PL (`https://push.buzz.xyz/v1/...`). It is a cross-route
|
||||
/// domain-separation string, not a deployment URL: the gateway hardcodes it
|
||||
/// regardless of where it is hosted, so clients must never derive it from a
|
||||
/// discovered gateway base URL or relay host.
|
||||
public enum BuzzPushTranscript {
|
||||
// MARK: Domains
|
||||
|
||||
public static let enrollDomain = "buzz.push.enroll.v1"
|
||||
public static let delegateDomain = "buzz.push.delegate.v1"
|
||||
public static let rotateEndpointDomain = "buzz.push.rotate-endpoint.v1"
|
||||
public static let revokeDelegationDomain = "buzz.push.revoke-delegation.v1"
|
||||
public static let revokeInstallationDomain = "buzz.push.revoke-installation.v1"
|
||||
|
||||
// MARK: Fixed audiences (protocol constants, see type docs)
|
||||
|
||||
public static let enrollAudience = "https://push.buzz.xyz/v1/installations"
|
||||
public static let delegateAudience = "https://push.buzz.xyz/v1/delegations"
|
||||
public static let rotateEndpointAudience = "https://push.buzz.xyz/v1/installations/endpoint"
|
||||
public static let revokeDelegationAudience = "https://push.buzz.xyz/v1/delegations/revoke"
|
||||
public static let revokeInstallationAudience = "https://push.buzz.xyz/v1/installations/revoke"
|
||||
|
||||
/// Wire version pinned by NIP-PL. Every transcript carries `"v":1`.
|
||||
public static let wireVersion: Int64 = 1
|
||||
|
||||
// MARK: Transcripts
|
||||
|
||||
/// `buzz.push.enroll.v1` — these exact bytes are the App Attest
|
||||
/// `clientData` supplied to attestation verification.
|
||||
public static func enroll(
|
||||
challengeId: UUID,
|
||||
challenge: String,
|
||||
keyId: String,
|
||||
appProfile: String,
|
||||
endpoint: String,
|
||||
endpointEpoch: Int64,
|
||||
expiresAt: Int64
|
||||
) throws -> Data {
|
||||
var o = CanonicalObject()
|
||||
o.int("v", wireVersion)
|
||||
try o.string("audience", Self.enrollAudience)
|
||||
o.uuid("challenge_id", challengeId)
|
||||
try o.string("challenge", challenge, field: "challenge")
|
||||
try o.string("key_id", keyId, field: "key_id")
|
||||
try o.string("app_profile", appProfile, field: "app_profile")
|
||||
try o.string("endpoint", endpoint, field: "endpoint")
|
||||
o.int("endpoint_epoch", endpointEpoch)
|
||||
o.int("expires_at", expiresAt)
|
||||
return encode(domain: enrollDomain, object: o)
|
||||
}
|
||||
|
||||
/// `buzz.push.delegate.v1` — `SHA-256(bytes)` is the assertion
|
||||
/// `clientDataHash`.
|
||||
public static func delegate(
|
||||
challengeId: UUID,
|
||||
challenge: String,
|
||||
installationHandle: UUID,
|
||||
endpointEpoch: Int64,
|
||||
generation: Int64,
|
||||
relayPubkey: String,
|
||||
notBefore: Int64,
|
||||
expiresAt: Int64
|
||||
) throws -> Data {
|
||||
var o = CanonicalObject()
|
||||
o.int("v", wireVersion)
|
||||
try o.string("audience", Self.delegateAudience)
|
||||
o.uuid("challenge_id", challengeId)
|
||||
try o.string("challenge", challenge, field: "challenge")
|
||||
o.uuid("installation_handle", installationHandle)
|
||||
o.int("endpoint_epoch", endpointEpoch)
|
||||
o.int("generation", generation)
|
||||
try o.string("relay_pubkey", relayPubkey, field: "relay_pubkey")
|
||||
o.int("not_before", notBefore)
|
||||
o.int("expires_at", expiresAt)
|
||||
return encode(domain: delegateDomain, object: o)
|
||||
}
|
||||
|
||||
/// `buzz.push.rotate-endpoint.v1` — `SHA-256(bytes)` is the assertion
|
||||
/// `clientDataHash`.
|
||||
public static func rotateEndpoint(
|
||||
challengeId: UUID,
|
||||
challenge: String,
|
||||
installationHandle: UUID,
|
||||
endpointEpoch: Int64,
|
||||
newEndpointEpoch: Int64,
|
||||
endpoint: String
|
||||
) throws -> Data {
|
||||
var o = CanonicalObject()
|
||||
o.int("v", wireVersion)
|
||||
try o.string("audience", Self.rotateEndpointAudience)
|
||||
o.uuid("challenge_id", challengeId)
|
||||
try o.string("challenge", challenge, field: "challenge")
|
||||
o.uuid("installation_handle", installationHandle)
|
||||
o.int("endpoint_epoch", endpointEpoch)
|
||||
o.int("new_endpoint_epoch", newEndpointEpoch)
|
||||
try o.string("endpoint", endpoint, field: "endpoint")
|
||||
return encode(domain: rotateEndpointDomain, object: o)
|
||||
}
|
||||
|
||||
/// `buzz.push.revoke-delegation.v1` — `SHA-256(bytes)` is the assertion
|
||||
/// `clientDataHash`.
|
||||
public static func revokeDelegation(
|
||||
challengeId: UUID,
|
||||
challenge: String,
|
||||
installationHandle: UUID,
|
||||
relayPubkey: String,
|
||||
generation: Int64
|
||||
) throws -> Data {
|
||||
var o = CanonicalObject()
|
||||
o.int("v", wireVersion)
|
||||
try o.string("audience", Self.revokeDelegationAudience)
|
||||
o.uuid("challenge_id", challengeId)
|
||||
try o.string("challenge", challenge, field: "challenge")
|
||||
o.uuid("installation_handle", installationHandle)
|
||||
try o.string("relay_pubkey", relayPubkey, field: "relay_pubkey")
|
||||
o.int("generation", generation)
|
||||
return encode(domain: revokeDelegationDomain, object: o)
|
||||
}
|
||||
|
||||
/// `buzz.push.revoke-installation.v1` — `SHA-256(bytes)` is the assertion
|
||||
/// `clientDataHash`.
|
||||
public static func revokeInstallation(
|
||||
challengeId: UUID,
|
||||
challenge: String,
|
||||
installationHandle: UUID,
|
||||
endpointEpoch: Int64,
|
||||
newEndpointEpoch: Int64
|
||||
) throws -> Data {
|
||||
var o = CanonicalObject()
|
||||
o.int("v", wireVersion)
|
||||
try o.string("audience", Self.revokeInstallationAudience)
|
||||
o.uuid("challenge_id", challengeId)
|
||||
try o.string("challenge", challenge, field: "challenge")
|
||||
o.uuid("installation_handle", installationHandle)
|
||||
o.int("endpoint_epoch", endpointEpoch)
|
||||
o.int("new_endpoint_epoch", newEndpointEpoch)
|
||||
return encode(domain: revokeInstallationDomain, object: o)
|
||||
}
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
private static func encode(domain: String, object: CanonicalObject) -> Data {
|
||||
Data((domain + "\n" + object.encoded()).utf8)
|
||||
}
|
||||
|
||||
/// Ordered compact JSON object writer. Emission order == call order;
|
||||
/// there is deliberately no sorting, no whitespace, and no `Encodable`
|
||||
/// round-trip anywhere near these bytes.
|
||||
struct CanonicalObject {
|
||||
private var members: [String] = []
|
||||
|
||||
mutating func int(_ key: String, _ value: Int64) {
|
||||
// Swift's Int64 description is shortest decimal notation, which
|
||||
// is what the spec pins and what serde_json emits.
|
||||
members.append("\"\(key)\":\(value)")
|
||||
}
|
||||
|
||||
mutating func uuid(_ key: String, _ value: UUID) {
|
||||
// Canonical lowercase-hyphenated form, matching uuid::Uuid's
|
||||
// serde serialization. Foundation's uuidString is uppercase.
|
||||
members.append("\"\(key)\":\"\(value.uuidString.lowercased())\"")
|
||||
}
|
||||
|
||||
mutating func string(_ key: String, _ value: String, field: String? = nil) throws {
|
||||
members.append("\"\(key)\":\"\(try Self.escape(value, field: field ?? key))\"")
|
||||
}
|
||||
|
||||
func encoded() -> String {
|
||||
"{" + members.joined(separator: ",") + "}"
|
||||
}
|
||||
|
||||
/// Minimal JSON string escaping, byte-identical to serde_json:
|
||||
/// `"` and `\` get two-character escapes; U+0008, U+0009, U+000A,
|
||||
/// U+000C, U+000D get their short forms; the remaining C0 controls
|
||||
/// get lowercase `\u00xx`. Nothing else is escaped (in particular
|
||||
/// `/` is NOT escaped — the JSONSerialization behavior that makes it
|
||||
/// unusable here). Non-ASCII input is rejected outright.
|
||||
static func escape(_ s: String, field: String) throws -> String {
|
||||
var out = String()
|
||||
out.reserveCapacity(s.count)
|
||||
for scalar in s.unicodeScalars {
|
||||
switch scalar.value {
|
||||
case 0x22: out += "\\\""
|
||||
case 0x5C: out += "\\\\"
|
||||
case 0x08: out += "\\b"
|
||||
case 0x09: out += "\\t"
|
||||
case 0x0A: out += "\\n"
|
||||
case 0x0C: out += "\\f"
|
||||
case 0x0D: out += "\\r"
|
||||
case 0x00...0x1F: out += String(format: "\\u%04x", scalar.value)
|
||||
case 0x20...0x7E: out.unicodeScalars.append(scalar)
|
||||
default: throw BuzzPushTranscriptError.nonASCIIInput(field: field)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@testable import BuzzPushKit
|
||||
|
||||
/// Replays the gateway-generated known-answer vectors
|
||||
/// (`crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json`)
|
||||
/// against the Swift canonical encoder. Byte-for-byte transcript equality and
|
||||
/// SHA-256 equality are both asserted, so a drift on either side breaks a test
|
||||
/// instead of silently stranding iOS clients with `401 invalid_attestation`.
|
||||
final class BuzzPushTranscriptTests: XCTestCase {
|
||||
// MARK: Fixture
|
||||
|
||||
struct Fixture: Decodable {
|
||||
struct Vector: Decodable {
|
||||
let name: String
|
||||
let domain: String
|
||||
let transcript: String
|
||||
let sha256: String
|
||||
}
|
||||
|
||||
let vectors: [Vector]
|
||||
}
|
||||
|
||||
static let fixture: Fixture = {
|
||||
// Tests/BuzzPushKitTests/… → repo root is five levels up from this file's dir.
|
||||
let here = URL(fileURLWithPath: #filePath)
|
||||
let repoRoot = here
|
||||
.deletingLastPathComponent() // BuzzPushKitTests
|
||||
.deletingLastPathComponent() // Tests
|
||||
.deletingLastPathComponent() // BuzzPushKit
|
||||
.deletingLastPathComponent() // ios
|
||||
.deletingLastPathComponent() // mobile
|
||||
.deletingLastPathComponent() // repo root
|
||||
let path = repoRoot
|
||||
.appendingPathComponent("crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json")
|
||||
let data = try! Data(contentsOf: path)
|
||||
return try! JSONDecoder().decode(Fixture.self, from: data)
|
||||
}()
|
||||
|
||||
// Deterministic inputs mirroring the fixture's `inputs` block.
|
||||
static let challengeId = UUID(uuidString: "11111111-1111-4111-8111-111111111111")!
|
||||
static let installationHandle = UUID(uuidString: "22222222-2222-4222-8222-222222222222")!
|
||||
static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
|
||||
static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo="
|
||||
static let appProfile = "buzz-ios-production"
|
||||
static let endpoint = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
|
||||
static let relayPubkey = String(repeating: "a", count: 64)
|
||||
static let notBefore: Int64 = 1_752_620_000
|
||||
static let expiresAt: Int64 = 1_752_624_000
|
||||
|
||||
private func assertMatchesVector(_ name: String, _ bytes: Data,
|
||||
file: StaticString = #filePath, line: UInt = #line) throws {
|
||||
guard let vector = Self.fixture.vectors.first(where: { $0.name == name }) else {
|
||||
XCTFail("missing fixture vector \(name)", file: file, line: line)
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(String(decoding: bytes, as: UTF8.self), vector.transcript,
|
||||
"\(name) transcript bytes drifted from gateway ground truth",
|
||||
file: file, line: line)
|
||||
let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined()
|
||||
XCTAssertEqual(digest, vector.sha256,
|
||||
"\(name) sha256 drifted from gateway ground truth",
|
||||
file: file, line: line)
|
||||
}
|
||||
|
||||
// MARK: Known-answer vectors
|
||||
|
||||
func testEnrollVector() throws {
|
||||
try assertMatchesVector("enroll", BuzzPushTranscript.enroll(
|
||||
challengeId: Self.challengeId,
|
||||
challenge: Self.challenge,
|
||||
keyId: Self.keyId,
|
||||
appProfile: Self.appProfile,
|
||||
endpoint: Self.endpoint,
|
||||
endpointEpoch: 1,
|
||||
expiresAt: Self.expiresAt
|
||||
))
|
||||
}
|
||||
|
||||
func testDelegateVector() throws {
|
||||
try assertMatchesVector("delegate", BuzzPushTranscript.delegate(
|
||||
challengeId: Self.challengeId,
|
||||
challenge: Self.challenge,
|
||||
installationHandle: Self.installationHandle,
|
||||
endpointEpoch: 1,
|
||||
generation: 1,
|
||||
relayPubkey: Self.relayPubkey,
|
||||
notBefore: Self.notBefore,
|
||||
expiresAt: Self.expiresAt
|
||||
))
|
||||
}
|
||||
|
||||
func testRotateEndpointVector() throws {
|
||||
try assertMatchesVector("rotate_endpoint", BuzzPushTranscript.rotateEndpoint(
|
||||
challengeId: Self.challengeId,
|
||||
challenge: Self.challenge,
|
||||
installationHandle: Self.installationHandle,
|
||||
endpointEpoch: 1,
|
||||
newEndpointEpoch: 2,
|
||||
endpoint: Self.endpoint
|
||||
))
|
||||
}
|
||||
|
||||
func testRevokeDelegationVector() throws {
|
||||
try assertMatchesVector("revoke_delegation", BuzzPushTranscript.revokeDelegation(
|
||||
challengeId: Self.challengeId,
|
||||
challenge: Self.challenge,
|
||||
installationHandle: Self.installationHandle,
|
||||
relayPubkey: Self.relayPubkey,
|
||||
generation: 2
|
||||
))
|
||||
}
|
||||
|
||||
func testRevokeInstallationVector() throws {
|
||||
try assertMatchesVector("revoke_installation", BuzzPushTranscript.revokeInstallation(
|
||||
challengeId: Self.challengeId,
|
||||
challenge: Self.challenge,
|
||||
installationHandle: Self.installationHandle,
|
||||
endpointEpoch: 1,
|
||||
newEndpointEpoch: 2
|
||||
))
|
||||
}
|
||||
|
||||
func testAllFixtureVectorsCovered() {
|
||||
XCTAssertEqual(
|
||||
Set(Self.fixture.vectors.map(\.name)),
|
||||
["enroll", "delegate", "rotate_endpoint", "revoke_delegation", "revoke_installation"],
|
||||
"fixture gained or lost a vector; add/remove the matching known-answer test"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Escaping edges (the exact JSONSerialization failure modes)
|
||||
|
||||
func testSolidusIsNotEscaped() throws {
|
||||
// The whole reason this encoder exists: '/' must pass through raw.
|
||||
XCTAssertEqual(try BuzzPushTranscript.CanonicalObject.escape("https://push.buzz.xyz/v1", field: "audience"),
|
||||
"https://push.buzz.xyz/v1")
|
||||
}
|
||||
|
||||
func testMinimalEscaping() throws {
|
||||
XCTAssertEqual(try BuzzPushTranscript.CanonicalObject.escape("a\"b\\c\u{08}\u{09}\u{0A}\u{0C}\u{0D}\u{01}", field: "x"),
|
||||
"a\\\"b\\\\c\\b\\t\\n\\f\\r\\u0001")
|
||||
}
|
||||
|
||||
func testNonASCIIRejected() {
|
||||
XCTAssertThrowsError(try BuzzPushTranscript.CanonicalObject.escape("caf\u{00E9}", field: "app_profile")) {
|
||||
XCTAssertEqual($0 as? BuzzPushTranscriptError, .nonASCIIInput(field: "app_profile"))
|
||||
}
|
||||
}
|
||||
|
||||
func testUUIDLowercased() throws {
|
||||
var o = BuzzPushTranscript.CanonicalObject()
|
||||
o.uuid("k", UUID(uuidString: "ABCDEF12-3456-4789-8ABC-DEF123456789")!)
|
||||
XCTAssertEqual(o.encoded(), "{\"k\":\"abcdef12-3456-4789-8abc-def123456789\"}")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user