diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 0564972c07..9005a0f600 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -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 = (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(), + ); + } +} diff --git a/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json new file mode 100644 index 0000000000..7c2cedf6eb --- /dev/null +++ b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json @@ -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" + } + ] +}