From 3b9f4005e4254239e8232e08f83730e5a9125006 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Thu, 16 Jul 2026 17:56:14 +0200 Subject: [PATCH 1/3] test(core): golden wire-format fixtures pin VERSION_V3 in-repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiny golden ciphertexts (both containers, sealed by the published @e4a/pg-wasm 0.6.1) committed with the key material to unseal them, plus tests pinning the literal PRELUDE/VERSION_V3/preamble constants. A change to consts.rs, the bincode framing, or the header layout now fails `cargo test -p pg-core` immediately in this repo — the same tripwire the e2e harness runs externally (postguard-e2e fixtures-verify), but without needing a sibling checkout. Closes #211. --- pg-core/testdata/wire-format-v3/README.md | 12 ++ pg-core/testdata/wire-format-v3/mem.bin | Bin 0 -> 854 bytes pg-core/testdata/wire-format-v3/meta.json | 18 +++ pg-core/testdata/wire-format-v3/stream.bin | Bin 0 -> 859 bytes pg-core/testdata/wire-format-v3/usk.json | 1 + pg-core/testdata/wire-format-v3/vk.json | 1 + pg-core/tests/wire_format.rs | 151 +++++++++++++++++++++ 7 files changed, 183 insertions(+) create mode 100644 pg-core/testdata/wire-format-v3/README.md create mode 100644 pg-core/testdata/wire-format-v3/mem.bin create mode 100644 pg-core/testdata/wire-format-v3/meta.json create mode 100644 pg-core/testdata/wire-format-v3/stream.bin create mode 100644 pg-core/testdata/wire-format-v3/usk.json create mode 100644 pg-core/testdata/wire-format-v3/vk.json create mode 100644 pg-core/tests/wire_format.rs 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 0000000000000000000000000000000000000000..1028de57df8b0122c0a73b8bb7c62029627d3039 GIT binary patch literal 854 zcmWgX>RZmh#K6GV%g6u$Tu{0oBSkkOKPM%%Xw$WbERY~fg&b5xW>IdUZc1uyzFttM zUU5ljN@`w-b7E17UQTLRY7R^_!{SP<8VlCX31KWc{+oTZ&wso(v0kYma@qa877zCE z)hy?~dgyrUzve|=KUdy(==Y~{$sx5*2d3-CGJQJWVB3?j?%pQ#`)k)I1Rd`!{=LYs z=~Lh@ruS_vj26sk)5@b(?Rl`{;=#lx%}YNNytBKY@o?>}SIuuOzHHO{m&y?Jv}Skf zAydcXZ5d02qq9<*SY}nRIILLKT=?}&O^?&fYg@LkF<*1l+#@*W`MhrB-Ljr++fGgI zUz?QOp{u`Hq4MjSk9Xx~{F{C87>lliVBCUZm)p`qH`pk?_{%-hUQXt-=Y)p(RT;l8 zcFS;w)$^U4wch^Lw+DZ&)z1F1J?O}ZDB(xuwdaoY(h$!;53daQYFfl5nWEGW=CA;G(L*$=&Mw>aXq-_iULb|cinEmJ(= zOW^)A`TqGoc~pFKd>#DfDm+OoH9yzH;Mp1w@Y*w}V!^CUUlxA){wYz`){N)Sxtt%X0zy3)Z{-yK=M&NlpM@XXImz;U__}4wJhS|d z|6n$V3-9&oKjtT}%B*6t`igxMpPhU0IPBEVf9qdz9@SvIEag+T@1|YBduv@m>B`8I zLqS{*j%~5rn0Wu$?M8-Xu1KXd@3UU(cBLGh@Oc0G4}W%xuQ}gg``B&iXT{F<*8mO# Bf06(I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..77828d15ea0d907e3e298774d5b8579e026541d0 GIT binary patch literal 859 zcmWgX>RZmh#K6EfjgbKYxS(`FMv87meojhi(WYw=Ss+1}3OT5X%%a>x-IUbae7&Gh zz2cJ6l+?Tu=ft8Ey`0pt)Et;CGCr5hKyk-ks zy#3|g6xWQuNv;3R?ELYtuW!d%``nf1eiiSw4BE8Vy~#IBPEg5P<-J8w&@YLvxlNJ^ zaVI8ZWHCKyV0;l^f8RviWs`uo>aAnZKOdjD`D;Sqq7>(?PW($g{5tZ}^WCzA|E(KZ z;sZOo3vN2MC_cWqH1zhNS&w{I+~xUoS$4}d0pZxE8`++J`*=r|&1dg}?FF9}F4C0# z&mb1Hpef?&|H+;Ocj{C^-S(cWU9&JR^!O6xh{83s7VdkzFJE~(_n~QR_8ND~mWn$4 z2}{fWYRtdW;|vPPTHDrypQ2q%0t=U#LPC}W8o&^0?Kh1o&97QJH>cd%_@gRaamkWd zuVvzkY;ROE*k9b_;g&S9{`|!?_i|4kkgs;UbX|q>n9(7FJrZ*cad14^6Uo=kaOhmm zjH4E^><=U!ShV#rJ(_X&rpg@EZ?8iqXI@)s-DiDLB$wGptl9VQQDHsDym0LoF^4SEeARa?QZBvjn;Lq}b^{~ltEJ1nwY!B_ zPx_|`#Kdp1Qq9+wl;M>}_deh@)IuEpE4_G8^JjE}OHS6&a#y@QA zM+7}JUWxzAnK+eI?n1}>t^Zo)N{4=&bUpNq+yk!e&9^=71RYshU7+726y|lkHug4W xZ{)>0_q~MsxQkL$zRvdN^Z4pfU9`xu(djjJ 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] +fn golden_stream_fixture_unseals() { + 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 +} From 776f3baf1046bc0cb0c1ffdff812080a1488c23f Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Thu, 16 Jul 2026 18:15:14 +0200 Subject: [PATCH 2/3] test(core): run the wire-format tripwires on default features The file-level stream gate meant a plain cargo test -p pg-core ran zero of these tests, contradicting the README's 'fails immediately' claim. Now the file gates on the default rust feature (constants, preamble, and the in-memory unseal always run) and only the streaming-container test requires the stream feature (still covered in CI, which tests with test,rust,stream). --- pg-core/tests/wire_format.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pg-core/tests/wire_format.rs b/pg-core/tests/wire_format.rs index 33ae4d8..5234a26 100644 --- a/pg-core/tests/wire_format.rs +++ b/pg-core/tests/wire_format.rs @@ -7,14 +7,17 @@ //! 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. -#![cfg(all(feature = "rust", feature = "stream"))] +//! +//! 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::stream::UnsealerStreamConfig; use pg_core::client::rust::UnsealerMemoryConfig; use pg_core::client::Unsealer; use pg_core::consts::{PREAMBLE_SIZE, PRELUDE, PRELUDE_SIZE, VERSION_SIZE, VERSION_V3}; @@ -107,7 +110,10 @@ fn golden_mem_fixture_unseals() { /// 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"); From 61e1cd80cbcce42a9a02ebd9e3c3fabbad92b977 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Thu, 16 Jul 2026 18:23:15 +0200 Subject: [PATCH 3/3] style(core): drop needless borrow in wire-format test (clippy) --- pg-core/tests/wire_format.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pg-core/tests/wire_format.rs b/pg-core/tests/wire_format.rs index 5234a26..2c51d6c 100644 --- a/pg-core/tests/wire_format.rs +++ b/pg-core/tests/wire_format.rs @@ -99,7 +99,7 @@ fn golden_fixtures_carry_the_pinned_preamble() { #[test] fn golden_mem_fixture_unseals() { let f = fixture(); - let unsealer = Unsealer::, UnsealerMemoryConfig>::new(&testdata("mem.bin"), &f.vk) + 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)