test(push-gateway): pin App Attest transcript bytes with known-answer vectors

Add tests/vectors/app_attest_transcripts.json — deterministic
known-answer vectors (exact transcript bytes + SHA-256) for all five
NIP-PL App Attest operations: enroll, delegate, rotate_endpoint,
revoke_delegation, revoke_installation — generated by the gateway's
own transcript() encoder, plus in-crate tests asserting the encoder
reproduces the fixture byte-for-byte.

The fixture is shared ground truth for client-side canonical encoders
(the Swift NIP-PL iOS client): a client that fails to reproduce these
bytes exactly fails every attested call with invalid_attestation and
no diagnostic (Swift JSONSerialization's \/ escaping is the known
trap). Any future change to the Rust encoder now breaks a test
instead of silently stranding clients.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-15 17:58:09 -04:00
parent 7fbdacb03f
commit a5098e990a
2 changed files with 191 additions and 0 deletions

View File

@@ -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(),
);
}
}

View File

@@ -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"
}
]
}