Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions pg-core/testdata/wire-format-v3/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added pg-core/testdata/wire-format-v3/mem.bin
Binary file not shown.
18 changes: 18 additions & 0 deletions pg-core/testdata/wire-format-v3/meta.json
Original file line number Diff line number Diff line change
@@ -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
}
Binary file added pg-core/testdata/wire-format-v3/stream.bin
Binary file not shown.
1 change: 1 addition & 0 deletions pg-core/testdata/wire-format-v3/usk.json
Original file line number Diff line number Diff line change
@@ -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"}
1 change: 1 addition & 0 deletions pg-core/testdata/wire-format-v3/vk.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"formatVersion":0,"publicKey":"rNxi2LLU7MyaEBcR60cE+aNGgEg1juZnDnSGaWZrMl4="}
157 changes: 157 additions & 0 deletions pg-core/tests/wire_format.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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<CGWKV>,
want: Vec<u8>,
}

fn fixture() -> Fixture {
#[derive(Deserialize)]
struct UskResponse {
key: UserSecretKey<CGWKV>,
}

let meta: Meta = serde_json::from_slice(&testdata("meta.json")).expect("parse meta.json");
let vk: Parameters<VerifyingKey> =
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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file-level gate requires the stream feature, but only golden_stream_fixture_unseals needs it — wire_constants_are_pinned needs no feature and golden_mem_fixture_unseals needs only rust (the default). Since stream is not a default feature (default = ["rust"]), plain cargo test -p pg-core runs running 0 tests here, so the README/module-doc claim that a format change "fails cargo test -p pg-core immediately" is only true with the stream feature.

Two ways to make the claim hold: gate the file on #![cfg(feature = "rust")] and put #[cfg(feature = "stream")] on just the stream test — or, if the stream-feature requirement is intended, tweak the README/doc wording to say the tripwire needs --features stream. Non-blocking.

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::<Vec<u8>, 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<u8> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small accuracy nit, non-blocking: base64ct is already a regular (non-dev, non-optional) dependency of pg-core (Cargo.toml line 25, used for constant-time base64 in artifacts.rs), so the "avoids a dev-dependency" justification here is not quite right — nothing would be added to the dep tree.

That said, the swap is not entirely free: artifacts.rs uses the buffer-based Base64::decode(v, buf) and base64ct is pulled in without the alloc feature, so Base64::decode_vec would need features = ["alloc"] enabled (or decode into a sized buffer). Given the decoder is tiny and proven correct by the unseal test, this is genuinely optional — feel free to leave it. If you keep it, consider tweaking the comment so it does not claim base64ct is absent.

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
}
Loading