Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
a5098e9
test(push-gateway): pin App Attest transcript bytes with known-answer…
Jul 15, 2026
88be670
feat(mobile/ios): Swift canonical NIP-PL App Attest transcript encoder
Jul 15, 2026
e4b1e81
feat(mobile/ios): add NIP-PL notification service foundation
Jul 15, 2026
4c99a83
fix(mobile/ios): secure push relay resolution
Jul 24, 2026
14a529d
Merge origin/main into push-ios-blockers-1-2
Jul 24, 2026
e067a39
fix(mobile/ios): synchronize push snapshot lifecycle
Jul 24, 2026
8912fdc
fix(mobile/ios): harden push lifecycle failures
Jul 24, 2026
7a3eae8
feat(mobile/ios): add development push enrollment
Jul 28, 2026
eb02109
fix(push): enable HTTP/2 for APNs
Jul 28, 2026
0f06925
feat(mobile/ios): publish development push leases
Jul 28, 2026
a8eabb6
fix(push): authenticate APNs with certificate
Jul 29, 2026
a352059
Merge origin/main into push-ios-blockers-1-2
Jul 31, 2026
80001b6
fix(push): restrict APNs certificate secret
Jul 31, 2026
424f2fe
refactor(push): remove dead credential refresh path
Jul 31, 2026
c7bbf74
fix(push): reject legacy APNs chart values
Jul 31, 2026
89de724
Merge remote-tracking branch 'origin/main' into apns-http2
Jul 31, 2026
f61f841
Merge apns-http2 (APNs certificate auth) into push-ios-blockers-1-2
Jul 31, 2026
0e3389a
fix(push): pin gateway tag creation contract
Jul 31, 2026
5d236db
feat(push): add gated development App Attest bypass
Jul 31, 2026
421912d
refactor(push): require validated App Attest bypass token
Jul 31, 2026
f2cbd4f
fix(push): reject invalid App Attest bypass flags
Jul 31, 2026
e03b42b
feat(push): allow Debug loopback gateway delivery
Jul 31, 2026
f8a1b12
Merge remote-tracking branch 'origin/main' into push-ios-blockers-1-2
Jul 31, 2026
b1b16b2
revert: keep push gateway delivery HTTPS only
Jul 31, 2026
889501a
revert: restore Debug loopback gateway delivery
Jul 31, 2026
922aa58
fix(push): explicitly gate Debug loopback delivery
Jul 31, 2026
3ace1e8
revert: preserve canonical HTTPS push delivery
Jul 31, 2026
7374575
test(push): pin App Attest bypass as non-default
Jul 31, 2026
4f86c6c
fix(mobile/ios): align Apple app identities
Jul 31, 2026
029828a
test(mobile/ios): assert signing configuration mapping
Jul 31, 2026
b4df1c0
test(mobile/ios): resolve signing config references
Aug 1, 2026
bb5fcd4
test(mobile/ios): follow target configuration graph
Aug 1, 2026
9f5c895
test(mobile/ios): run Swift push tests in CI
Aug 1, 2026
db73dc4
Merge main into push iOS blockers
Aug 1, 2026
32915c5
test(mobile/ios): exercise push notification resolver
Aug 1, 2026
c49dc2b
test(mobile/ios): harden project source checks
Aug 1, 2026
3dd4580
test(mobile/ios): validate project semantics
Aug 1, 2026
c5f6770
test(mobile/ios): pin tracked identity declarations
Aug 1, 2026
aea7821
fix(mobile/ios): enable Debug compilation condition
Aug 1, 2026
52fa0d2
fix(mobile/ios): configure push gateway URL
Aug 1, 2026
f563f83
test(mobile): honor compiled push gateway
Aug 2, 2026
4a9b4d9
test(push): cover strict App Attest verification
Aug 2, 2026
99cb60b
feat(ios): add real App Attest client
Aug 2, 2026
b3875f6
test(ios): harden signing config contract
Aug 2, 2026
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