Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions crates/buzz-push-gateway/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -774,3 +774,146 @@ pub fn router_with_metrics(
}
(public, health)
}

/// Known-answer vectors for the exact App Attest transcript bytes defined by
/// NIP-PL ("Exact App Attest transcript construction"). The fixture file is
/// shared ground truth with client-side canonical encoders (the Swift NIP-PL
/// iOS client): a client encoder that fails to reproduce these bytes exactly
/// fails every enroll/delegate/rotate/revoke call with `invalid_attestation`.
#[cfg(test)]
mod transcript_vector_tests {
use super::*;
use sha2::{Digest, Sha256};

const VECTORS_JSON: &str = include_str!("../tests/vectors/app_attest_transcripts.json");

// Deterministic fixture inputs mirrored in the vector file's `inputs`.
const CHALLENGE_ID: uuid::Uuid =
uuid::Uuid::from_u128(0x1111_1111_1111_4111_8111_1111_1111_1111);
const INSTALLATION: uuid::Uuid =
uuid::Uuid::from_u128(0x2222_2222_2222_4222_8222_2222_2222_2222);
// base64url-no-pad of bytes 0x00..=0x1f.
const CHALLENGE: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
// Standard base64 (padded) of 32 bytes of 0xAA.
const KEY_ID: &str = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=";
// 32-byte APNs token, lowercase hex.
const ENDPOINT: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
const RELAY_PUBKEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";

fn assert_vector(name: &str, actual: &str) {
let file: serde_json::Value = serde_json::from_str(VECTORS_JSON).unwrap();
let vector = file["vectors"]
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == name)
.unwrap_or_else(|| panic!("vector {name} missing from fixture"));
assert_eq!(
actual,
vector["transcript"].as_str().unwrap(),
"{name} bytes"
);
assert_eq!(
hex::encode(Sha256::digest(actual.as_bytes())),
vector["sha256"].as_str().unwrap(),
"{name} sha256"
);
}

#[test]
fn fixture_encodings_match_their_raw_bytes() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let challenge_bytes: Vec<u8> = (0u8..32).collect();
assert_eq!(URL_SAFE_NO_PAD.encode(&challenge_bytes), CHALLENGE);
assert_eq!(STANDARD.encode([0xAAu8; 32]), KEY_ID);
assert_eq!(hex::decode(ENDPOINT).unwrap().len(), 32);
}

#[test]
fn enroll_transcript_vector() {
let t = EnrollTranscript {
v: 1,
audience: "https://push.buzz.xyz/v1/installations",
challenge_id: CHALLENGE_ID,
challenge: CHALLENGE,
key_id: KEY_ID,
app_profile: AppProfile::BuzzIosProduction,
endpoint: ENDPOINT,
endpoint_epoch: 1,
expires_at: 1_752_624_000,
};
assert_vector("enroll", &transcript("buzz.push.enroll.v1", &t).unwrap());
}

#[test]
fn delegate_transcript_vector() {
let t = DelegateTranscript {
v: 1,
audience: "https://push.buzz.xyz/v1/delegations",
challenge_id: CHALLENGE_ID,
challenge: CHALLENGE,
installation_handle: INSTALLATION,
endpoint_epoch: 1,
generation: 1,
relay_pubkey: RELAY_PUBKEY,
not_before: 1_752_620_000,
expires_at: 1_752_624_000,
};
assert_vector(
"delegate",
&transcript("buzz.push.delegate.v1", &t).unwrap(),
);
}

#[test]
fn rotate_endpoint_transcript_vector() {
let t = RotateTranscript {
v: 1,
audience: "https://push.buzz.xyz/v1/installations/endpoint",
challenge_id: CHALLENGE_ID,
challenge: CHALLENGE,
installation_handle: INSTALLATION,
endpoint_epoch: 1,
new_endpoint_epoch: 2,
endpoint: ENDPOINT,
};
assert_vector(
"rotate_endpoint",
&transcript("buzz.push.rotate-endpoint.v1", &t).unwrap(),
);
}

#[test]
fn revoke_delegation_transcript_vector() {
let t = RevokeDelegationTranscript {
v: 1,
audience: "https://push.buzz.xyz/v1/delegations/revoke",
challenge_id: CHALLENGE_ID,
challenge: CHALLENGE,
installation_handle: INSTALLATION,
relay_pubkey: RELAY_PUBKEY,
generation: 2,
};
assert_vector(
"revoke_delegation",
&transcript("buzz.push.revoke-delegation.v1", &t).unwrap(),
);
}

#[test]
fn revoke_installation_transcript_vector() {
let t = RevokeInstallationTranscript {
v: 1,
audience: "https://push.buzz.xyz/v1/installations/revoke",
challenge_id: CHALLENGE_ID,
challenge: CHALLENGE,
installation_handle: INSTALLATION,
endpoint_epoch: 1,
new_endpoint_epoch: 2,
};
assert_vector(
"revoke_installation",
&transcript("buzz.push.revoke-installation.v1", &t).unwrap(),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"description": "Known-answer vectors for the exact App Attest transcript bytes defined by NIP-PL ('Exact App Attest transcript construction'). Generated by the gateway's own transcript encoder (crates/buzz-push-gateway/src/http.rs transcript()). Client canonical encoders (Swift NIP-PL iOS client) MUST reproduce `transcript` byte-for-byte; `sha256` is the hex digest of those UTF-8 bytes (the App Attest clientDataHash input for assertion routes, and the exact clientData for enrollment).",
"inputs": {
"challenge_id": "11111111-1111-4111-8111-111111111111",
"installation_handle": "22222222-2222-4222-8222-222222222222",
"challenge": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
"challenge_note": "base64url-no-pad of bytes 0x00..0x1f",
"key_id": "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=",
"key_id_note": "standard base64 (padded) of 32 bytes of 0xAA",
"app_profile": "buzz-ios-production",
"endpoint": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"relay_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"not_before": 1752620000,
"expires_at": 1752624000
},
"vectors": [
{
"name": "enroll",
"domain": "buzz.push.enroll.v1",
"transcript": "buzz.push.enroll.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"key_id\":\"qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=\",\"app_profile\":\"buzz-ios-production\",\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\",\"endpoint_epoch\":1,\"expires_at\":1752624000}",
"sha256": "f8f41fd671918aa0b7dc8eca704b26a5c2748270c2efc7db7348e36a4cccd189"
},
{
"name": "delegate",
"domain": "buzz.push.delegate.v1",
"transcript": "buzz.push.delegate.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/delegations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"generation\":1,\"relay_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"not_before\":1752620000,\"expires_at\":1752624000}",
"sha256": "7466177cc2dc2a4f9a075fdbb461531692fc858778a171a5862b855cccfaa059"
},
{
"name": "rotate_endpoint",
"domain": "buzz.push.rotate-endpoint.v1",
"transcript": "buzz.push.rotate-endpoint.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations/endpoint\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"new_endpoint_epoch\":2,\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\"}",
"sha256": "601aba0c8d4021ddf97ce1e434b9c7ad1e051bf02a44929aaebd8c6bd724e7b3"
},
{
"name": "revoke_delegation",
"domain": "buzz.push.revoke-delegation.v1",
"transcript": "buzz.push.revoke-delegation.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/delegations/revoke\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"relay_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"generation\":2}",
"sha256": "d6bcd4b25235adcb519ef189820b08dd0386fc752fd4e4c77bc3ffb7a519a84a"
},
{
"name": "revoke_installation",
"domain": "buzz.push.revoke-installation.v1",
"transcript": "buzz.push.revoke-installation.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations/revoke\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"new_endpoint_epoch\":2}",
"sha256": "0ba51827af6586a5e1230e9b770b99544fb342efb55db3ab1ce499cf24a893c8"
}
]
}
20 changes: 20 additions & 0 deletions mobile/ios/BuzzPushKit/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// swift-tools-version:5.9
import PackageDescription

let package = Package(
name: "BuzzPushKit",
platforms: [.iOS(.v15), .macOS(.v12)],
products: [
.library(name: "BuzzPushKit", targets: ["BuzzPushKit"])
],
dependencies: [
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", exact: "0.21.1")
],
targets: [
.target(
name: "BuzzPushKit",
dependencies: [.product(name: "P256K", package: "swift-secp256k1")]
),
.testTarget(name: "BuzzPushKitTests", dependencies: ["BuzzPushKit"]),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

public struct APNsRegistrationUpdate: Equatable, Sendable {
public let method: String
public let arguments: [String: String]
public init(method: String, arguments: [String: String]) {
self.method = method
self.arguments = arguments
}
}

public final class APNsRegistrationBuffer {
public private(set) var pending: APNsRegistrationUpdate?
private var deliver: ((APNsRegistrationUpdate) -> Void)?
public init() {}
public func attach(_ deliver: @escaping (APNsRegistrationUpdate) -> Void) {
self.deliver = deliver
flush()
}
public func recordToken(_ token: Data) {
record(APNsRegistrationUpdate(
method: "apnsTokenChanged",
arguments: ["token": token.map { String(format: "%02x", $0) }.joined()]
))
}
public func recordError(_ message: String) {
record(APNsRegistrationUpdate(
method: "apnsRegistrationFailed", arguments: ["message": message]
))
}
private func record(_ update: APNsRegistrationUpdate) {
pending = update
flush()
}
private func flush() {
guard let pending, let deliver else { return }
self.pending = nil
deliver(pending)
}
}
Loading
Loading