diff --git a/pg-core/testdata/wire-format-v3/README.md b/pg-core/testdata/wire-format-v3/README.md new file mode 100644 index 0000000..436235a --- /dev/null +++ b/pg-core/testdata/wire-format-v3/README.md @@ -0,0 +1,12 @@ +Golden VERSION_V3 wire-format fixtures (EPIC issue #211): tiny ciphertexts in +both containers (streaming + in-memory), sealed by the published +`@e4a/pg-wasm@0.6.1`, committed with the verifying key and user secret key +needed to unseal them forever. `pg-core/tests/wire_format.rs` asserts they +still parse and decrypt — so a change to `consts.rs` (PRELUDE/VERSION_V3), +`bincode_compat`, or the header layout fails `cargo test -p pg-core` +immediately, in this repo, before it can reach clients. + +Generated by the e2e harness (`postguard-e2e` fixtures generator); the same +sets are verified there against published clients. To regenerate after a +DELIBERATE format change, see postguard-e2e `fixtures/README.md` — and keep +the old set, adding a new one: old ciphertexts don't disappear from inboxes. diff --git a/pg-core/testdata/wire-format-v3/mem.bin b/pg-core/testdata/wire-format-v3/mem.bin new file mode 100644 index 0000000..1028de5 Binary files /dev/null and b/pg-core/testdata/wire-format-v3/mem.bin differ diff --git a/pg-core/testdata/wire-format-v3/meta.json b/pg-core/testdata/wire-format-v3/meta.json new file mode 100644 index 0000000..3379a7b --- /dev/null +++ b/pg-core/testdata/wire-format-v3/meta.json @@ -0,0 +1,18 @@ +{ + "con": [ + { + "t": "irma-demo.RU.studentCard.level", + "v": "PhD" + } + ], + "files": { + "mem.bin": "in-memory container (seal / pg-js toBytes)", + "stream.bin": "streaming container (sealStream / cryptify / pg-js upload)" + }, + "generatedAt": "2026-07-16T13:03:47Z", + "plaintextB64": "cG9zdGd1YXJkLWUyZSBjcnlwdG8tY29tcGF0IGZpeHR1cmU6IG9sZCBjaXBoZXJ0ZXh0cyBtdXN0IGRlY3J5cHQgZm9yZXZlcg==", + "recipientId": "phd-holder", + "sealedBy": "@e4a/pg-wasm@0.6.1", + "senderEmail": "sender@golden.test", + "ts": 1784207026 +} \ No newline at end of file diff --git a/pg-core/testdata/wire-format-v3/stream.bin b/pg-core/testdata/wire-format-v3/stream.bin new file mode 100644 index 0000000..77828d1 Binary files /dev/null and b/pg-core/testdata/wire-format-v3/stream.bin differ diff --git a/pg-core/testdata/wire-format-v3/usk.json b/pg-core/testdata/wire-format-v3/usk.json new file mode 100644 index 0000000..d8e2147 --- /dev/null +++ b/pg-core/testdata/wire-format-v3/usk.json @@ -0,0 +1 @@ +{"status":"DONE","proofStatus":"VALID","key":"j/xqfDoVQlHcMjeejZv2k6WG/YbOqthksquli6oe9AYZqa4EslnYva1AYilab3RJFODq62ecYjCIXVdN+jqaAvsj27jxuh63OlAP93QWXV6KD78hGO6O0Piomnd4v/d5iCbI0xmu1ItIOJ0VgYOmI2UPfzz4npFM4Ts7LK5ZMzsBaj5VDMLMlXXml5fbsiQ7FcPiZciD0RjcprZwvX+WqGd0OQF0j0OGaW5yaXCqlwGoFagHhpVb7gX/aGcl0kBrt255f+eOAkh3ANdW4HiziaIz+Grpih5ovSKIpilc6WVof/xTbzMdleKO3WBL58OPF5ONSbZhVA1+70ldpYWW7QNKtoOJKzAkgk++nfUO8GNWHh4DmI1tCaLPYIIRLlbBitIeFcRJKZ8uGsRn8nNJOHKal/Lsh/23UtklHWwfIfAf9nu6pLyq/VitpncRXaeZE+/hJTenpkocs8Y/on8P1IrLci1B78myDWamW82yzlc67+rLfwgHu4xt2rA103WKrS2w0JdpP3Z3WDIOKQcyToyYr7C6HFr7BGRTRk5PlEhwoPd9B/n4Z3w7KhjePdI+D7STsy9ECYtDZ7+tbexe1p7LedJOSlXZY87mUk9dR67WiwQNZoywTWjsgUK8Dq/MsWeiBXsfuqMNhbffQAMdGvdodHFMcm2TehTXyaRL7VvOA9RD169pL4oo00Pah4egFX8Sx/wcsQckEXDVbbhB9AyHsWJWXZpUkz0QfS1GTsFhRpib6Twq2aEwLNw7uNvv"} \ No newline at end of file diff --git a/pg-core/testdata/wire-format-v3/vk.json b/pg-core/testdata/wire-format-v3/vk.json new file mode 100644 index 0000000..c22a5ac --- /dev/null +++ b/pg-core/testdata/wire-format-v3/vk.json @@ -0,0 +1 @@ +{"formatVersion":0,"publicKey":"rNxi2LLU7MyaEBcR60cE+aNGgEg1juZnDnSGaWZrMl4="} \ No newline at end of file diff --git a/pg-core/tests/wire_format.rs b/pg-core/tests/wire_format.rs new file mode 100644 index 0000000..2c51d6c --- /dev/null +++ b/pg-core/tests/wire_format.rs @@ -0,0 +1,157 @@ +//! Wire-format tripwire (EPIC issue #211). +//! +//! Pins the VERSION_V3 byte format against golden fixtures committed in +//! `testdata/wire-format-v3/` (sealed once by the published `@e4a/pg-wasm`, +//! with the key material to unseal them). If a change to `consts.rs`, the +//! bincode framing, or the header layout breaks these tests, every ciphertext +//! already in the wild — old encrypted emails and files — breaks identically. +//! That must be a deliberate, versioned decision: regenerate fixtures for the +//! NEW version (see the testdata README) and keep the old set decodable. +//! +//! Gated on the default `rust` feature so a plain `cargo test -p pg-core` +//! runs the tripwires; only the streaming-container test additionally needs +//! the `stream` feature (CI runs with `test,rust,stream`, covering all four). +#![cfg(feature = "rust")] + +use std::fs; +use std::path::PathBuf; + +use pg_core::api::Parameters; +use pg_core::artifacts::{UserSecretKey, VerifyingKey}; +use pg_core::client::rust::UnsealerMemoryConfig; +use pg_core::client::Unsealer; +use pg_core::consts::{PREAMBLE_SIZE, PRELUDE, PRELUDE_SIZE, VERSION_SIZE, VERSION_V3}; +use pg_core::kem::cgw_kv::CGWKV; + +use serde::Deserialize; + +fn testdata(name: &str) -> Vec { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata/wire-format-v3") + .join(name); + fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +#[derive(Deserialize)] +struct Meta { + #[serde(rename = "recipientId")] + recipient_id: String, + #[serde(rename = "plaintextB64")] + plaintext_b64: String, +} + +struct Fixture { + meta: Meta, + vk: VerifyingKey, + usk: UserSecretKey, + want: Vec, +} + +fn fixture() -> Fixture { + #[derive(Deserialize)] + struct UskResponse { + key: UserSecretKey, + } + + let meta: Meta = serde_json::from_slice(&testdata("meta.json")).expect("parse meta.json"); + let vk: Parameters = + serde_json::from_slice(&testdata("vk.json")).expect("parse vk.json"); + let usk: UskResponse = serde_json::from_slice(&testdata("usk.json")).expect("parse usk.json"); + let want = b64(&meta.plaintext_b64); + + Fixture { + meta, + vk: vk.public_key, + usk: usk.key, + want, + } +} + +/// The exact byte values of the wire constants. These are literals on +/// purpose: if someone edits `consts.rs`, this must fail — the constants ARE +/// the compatibility contract with every ciphertext ever produced. +#[test] +fn wire_constants_are_pinned() { + assert_eq!(PRELUDE, [0x14, 0x8A, 0x8E, 0xA7], "PRELUDE bytes changed"); + assert_eq!(VERSION_V3, 2, "VERSION_V3 changed"); + assert_eq!(PREAMBLE_SIZE, 10, "preamble layout changed"); +} + +/// Both golden containers begin with the documented 10-byte preamble: +/// PRELUDE (4) || VERSION_V3 big-endian (2) || header length (4). +#[test] +fn golden_fixtures_carry_the_pinned_preamble() { + for name in ["stream.bin", "mem.bin"] { + let ct = testdata(name); + assert!(ct.len() > PREAMBLE_SIZE, "{name}: too short"); + assert_eq!(&ct[..PRELUDE_SIZE], &PRELUDE, "{name}: prelude mismatch"); + let version = u16::from_be_bytes( + ct[PRELUDE_SIZE..PRELUDE_SIZE + VERSION_SIZE] + .try_into() + .unwrap(), + ); + assert_eq!(version, VERSION_V3, "{name}: version mismatch"); + } +} + +/// The in-memory golden container must keep parsing (bincode header framing) +/// and decrypting (KEM + AEAD + signature verification) forever. +#[test] +fn golden_mem_fixture_unseals() { + let f = fixture(); + let unsealer = Unsealer::, UnsealerMemoryConfig>::new(testdata("mem.bin"), &f.vk) + .expect("parse the golden in-memory container"); + let (plain, _verified) = unsealer + .unseal(&f.meta.recipient_id, &f.usk) + .expect("unseal the golden in-memory container"); + assert_eq!(plain, f.want, "decrypted plaintext changed"); +} + +/// The streaming golden container (what cryptify stores and clients upload) +/// must keep parsing and decrypting forever. +#[test] +#[cfg(feature = "stream")] +fn golden_stream_fixture_unseals() { + use pg_core::client::rust::stream::UnsealerStreamConfig; + + let f = fixture(); + let ct = testdata("stream.bin"); + + let mut plain = Vec::new(); + futures::executor::block_on(async { + let mut reader = futures::io::Cursor::new(&ct); + let unsealer = Unsealer::<_, UnsealerStreamConfig>::new(&mut reader, &f.vk) + .await + .expect("parse the golden streaming container"); + unsealer + .unseal(&f.meta.recipient_id, &f.usk, &mut plain) + .await + .expect("unseal the golden streaming container"); + }); + assert_eq!(plain, f.want, "decrypted plaintext changed"); +} + +/// Minimal std-only base64 decoder (standard alphabet, padded) — avoids a +/// dev-dependency for one meta field. +fn b64(s: &str) -> Vec { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let mut buf: u32 = 0; + let mut bits = 0; + for &c in s.as_bytes() { + if c == b'=' { + break; + } + let v = ALPHABET + .iter() + .position(|&a| a == c) + .unwrap_or_else(|| panic!("invalid base64 byte {c}")) as u32; + buf = (buf << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + } + } + out +}