From d8c30e9c005eca75e7322d6852be98ecc0748b80 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:55:14 +0000 Subject: [PATCH 1/4] feat(pg-core): challenge-signature API for proving key possession A holder of a PKG-issued signing key can now prove possession of it to a party that holds only the verifying key, without handing over a container. `sign_challenge`/`verify_challenge` sign a verifier-chosen challenge plus a context; `pg-wasm` exports them as `signChallenge`/`verifyChallenge`. The signed message is domain-separated with `CHALLENGE_DOMAIN`, applied by the signer and never passed in, so the result cannot double as a signature over a container header. Both variable-length parts are length-prefixed, so one (context, challenge) pair maps to exactly one message. All new items; nothing on the sealer, unsealer or wire paths is touched. Part of #338. Closes #362. --- pg-core/src/challenge.rs | 286 +++++++++++++++++++++++++++++++++++++++ pg-core/src/lib.rs | 1 + pg-wasm/src/lib.rs | 88 ++++++++++++ pg-wasm/tests/tests.rs | 77 +++++++++++ 4 files changed, 452 insertions(+) create mode 100644 pg-core/src/challenge.rs diff --git a/pg-core/src/challenge.rs b/pg-core/src/challenge.rs new file mode 100644 index 00000000..7734d514 --- /dev/null +++ b/pg-core/src/challenge.rs @@ -0,0 +1,286 @@ +//! Proving possession of a PKG-issued signing key. +//! +//! A holder of a [`SigningKeyExt`] signs a challenge chosen by the party that +//! wants the proof; that party verifies it with the [`VerifyingKey`] and the +//! [`Policy`] whose identity it expects. Nothing here touches a container: it +//! is a live proof that whoever is talking holds the signing key belonging to +//! an identity, not a statement about data at rest. +//! +//! The signing key that signs a challenge is the same one that signs container +//! headers, so a challenge signature must never be mistakable for a header +//! signature. [`CHALLENGE_DOMAIN`] is what keeps the two apart, and +//! [`sign_challenge`] applies it itself: a verifier hands over a challenge and +//! a context, never the leading bytes of the signed message. Were the domain +//! separator an argument, a malicious verifier could pass a serialized header +//! as the "challenge" and get back a signature valid on a container it wrote. +//! +//! `context` names what the proof is for — an endpoint, an upload id, a +//! session. It is signed alongside the challenge so a proof collected for one +//! purpose does not replay into another. +//! +//! ```rust +//! use pg_core::challenge::{sign_challenge, verify_challenge}; +//! # use pg_core::test::TestSetup; +//! +//! let mut rng = rand::thread_rng(); +//! # let setup = TestSetup::new(&mut rng); +//! let signing_key = &setup.signing_keys[0]; +//! +//! // The verifier picks the challenge; the signer never chooses it. +//! let challenge = b"32 random bytes from the verifier"; +//! +//! let sig = sign_challenge(signing_key, "cryptify/upload", challenge, &mut rng); +//! +//! assert!(verify_challenge( +//! &setup.ibs_pk, +//! &signing_key.policy, +//! "cryptify/upload", +//! challenge, +//! &sig, +//! )); +//! ``` + +use alloc::vec::Vec; + +use crate::artifacts::{SigningKeyExt, VerifyingKey}; +use crate::identity::Policy; + +use ibs::gg::{Signature, Signer, Verifier}; +use rand::{CryptoRng, RngCore}; + +/// Domain separator for upload-possession challenges. Applied by the +/// signer, never taken from the verifier's input. +pub const CHALLENGE_DOMAIN: &[u8] = b"postguard/challenge/v1"; + +/// Builds the message a challenge signature is made over. +/// +/// Both variable-length parts are length-prefixed rather than concatenated +/// raw, so exactly one `(context, challenge)` pair maps to any message. Plain +/// concatenation would make `("ab", "c")` and `("a", "bc")` the same bytes, +/// which turns a proof collected under one context into a proof under another. +fn challenge_message(context: &str, challenge: &[u8]) -> Vec { + let mut msg = Vec::with_capacity( + CHALLENGE_DOMAIN.len() + 2 * core::mem::size_of::() + context.len() + challenge.len(), + ); + + msg.extend_from_slice(CHALLENGE_DOMAIN); + msg.extend_from_slice(&(context.len() as u64).to_be_bytes()); + msg.extend_from_slice(context.as_bytes()); + msg.extend_from_slice(&(challenge.len() as u64).to_be_bytes()); + msg.extend_from_slice(challenge); + + msg +} + +/// Signs a verifier-chosen challenge, proving possession of `key`. +/// +/// [`CHALLENGE_DOMAIN`] is prepended here and cannot be opted out of, so the +/// result is never a valid signature over anything else PostGuard signs — see +/// the module documentation. +/// +/// # Arguments +/// +/// * `key` - The signing key to prove possession of. +/// * `context` - What the proof is for, e.g. an endpoint or an upload id. +/// * `challenge` - The bytes chosen by the verifier. +/// * `rng` - A cryptographically secure random number generator. +pub fn sign_challenge( + key: &SigningKeyExt, + context: &str, + challenge: &[u8], + rng: &mut R, +) -> Signature { + Signer::new() + .chain(challenge_message(context, challenge)) + .sign(&key.key.0, rng) +} + +/// Verifies a challenge signature against the identity derived from `pol`. +/// +/// Returns `false` for a signature that does not verify, and for a policy no +/// identity can be derived from. +/// +/// The identity comes from [`Policy::derive_ibs`], which canonicalizes +/// attribute values. So this answers "does the signer hold the key for the +/// identity this policy derives to", not "does the signer's policy read +/// exactly like this one": a policy spelling an e-mail address +/// `Alice@Example.COM` verifies against a key issued for +/// `alice@example.com`. A caller that keys on the raw attribute value has to +/// canonicalize it itself; the proof does not pin spelling. +/// +/// # Arguments +/// +/// * `vk` - The IBS verifying key (master public key). +/// * `pol` - The policy whose identity the signer is expected to hold a key for. +/// * `context` - The same context the signature was requested under. +/// * `challenge` - The bytes this verifier chose. +/// * `sig` - The signature to check. +pub fn verify_challenge( + vk: &VerifyingKey, + pol: &Policy, + context: &str, + challenge: &[u8], + sig: &Signature, +) -> bool { + let Ok(id) = pol.derive_ibs() else { + return false; + }; + + Verifier::default() + .chain(challenge_message(context, challenge)) + .verify(&vk.0, sig, &id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::Attribute; + use crate::test::TestSetup; + use alloc::vec; + + const CONTEXT: &str = "cryptify/upload"; + const CHALLENGE: &[u8] = b"a verifier-chosen challenge"; + + #[test] + fn test_challenge_roundtrip() { + let mut rng = rand::thread_rng(); + let setup = TestSetup::new(&mut rng); + let key = &setup.signing_keys[0]; + + let sig = sign_challenge(key, CONTEXT, CHALLENGE, &mut rng); + + assert!(verify_challenge( + &setup.ibs_pk, + &key.policy, + CONTEXT, + CHALLENGE, + &sig + )); + } + + #[test] + fn test_challenge_wrong_identity_fails() { + let mut rng = rand::thread_rng(); + let setup = TestSetup::new(&mut rng); + let key_a = &setup.signing_keys[0]; + let pol_b = &setup.signing_keys[1].policy; + + let sig = sign_challenge(key_a, CONTEXT, CHALLENGE, &mut rng); + + assert!(!verify_challenge( + &setup.ibs_pk, + pol_b, + CONTEXT, + CHALLENGE, + &sig + )); + } + + #[test] + fn test_challenge_wrong_challenge_fails() { + let mut rng = rand::thread_rng(); + let setup = TestSetup::new(&mut rng); + let key = &setup.signing_keys[0]; + + let sig = sign_challenge(key, CONTEXT, b"challenge X", &mut rng); + + assert!(!verify_challenge( + &setup.ibs_pk, + &key.policy, + CONTEXT, + b"challenge Y", + &sig + )); + } + + #[test] + fn test_challenge_wrong_context_fails() { + let mut rng = rand::thread_rng(); + let setup = TestSetup::new(&mut rng); + let key = &setup.signing_keys[0]; + + let sig = sign_challenge(key, "a", CHALLENGE, &mut rng); + + assert!(!verify_challenge( + &setup.ibs_pk, + &key.policy, + "b", + CHALLENGE, + &sig + )); + } + + /// Length prefixes are what make the signed message unambiguous. Without + /// them `("ab", "c")` and `("a", "bc")` concatenate to the same bytes, and + /// a proof handed over for one context replays into the other. + #[test] + fn test_challenge_split_is_unambiguous() { + let mut rng = rand::thread_rng(); + let setup = TestSetup::new(&mut rng); + let key = &setup.signing_keys[0]; + + let sig = sign_challenge(key, "ab", b"c", &mut rng); + + assert!(!verify_challenge( + &setup.ibs_pk, + &key.policy, + "a", + b"bc", + &sig + )); + + assert_ne!(challenge_message("ab", b"c"), challenge_message("a", b"bc")); + } + + /// A signature over the raw challenge, as the header path would make it, + /// is not a challenge proof: [`CHALLENGE_DOMAIN`] separates the two, which + /// is why no caller can pass it in. + #[test] + fn test_challenge_domain_separates_from_undomained_signature() { + let mut rng = rand::thread_rng(); + let setup = TestSetup::new(&mut rng); + let key = &setup.signing_keys[0]; + + let sig = Signer::new().chain(CHALLENGE).sign(&key.key.0, &mut rng); + + assert!(!verify_challenge( + &setup.ibs_pk, + &key.policy, + CONTEXT, + CHALLENGE, + &sig + )); + } + + /// `derive_ibs` canonicalizes, so a policy that spells the e-mail address + /// differently derives the same identity and verifies. Asserted rather + /// than assumed: a consumer keying on the raw attribute value has to + /// canonicalize it itself. + #[test] + fn test_challenge_verifies_under_non_canonical_policy() { + let mut rng = rand::thread_rng(); + let setup = TestSetup::new(&mut rng); + + // `signing_keys[0]` is issued for `alice@example.com`. + let key = &setup.signing_keys[0]; + let non_canonical = Policy { + timestamp: key.policy.timestamp, + con: vec![Attribute::new( + "pbdf.sidn-pbdf.email.email", + Some("Alice@Example.COM"), + )], + }; + + assert_ne!(non_canonical, key.policy); + + let sig = sign_challenge(key, CONTEXT, CHALLENGE, &mut rng); + + assert!(verify_challenge( + &setup.ibs_pk, + &non_canonical, + CONTEXT, + CHALLENGE, + &sig + )); + } +} diff --git a/pg-core/src/lib.rs b/pg-core/src/lib.rs index 778524f3..c1151271 100755 --- a/pg-core/src/lib.rs +++ b/pg-core/src/lib.rs @@ -175,6 +175,7 @@ pub mod api; pub mod artifacts; #[doc(hidden)] pub mod bincode_compat; +pub mod challenge; pub mod consts; pub mod error; pub mod identity; diff --git a/pg-wasm/src/lib.rs b/pg-wasm/src/lib.rs index 1c538621..4bfcb277 100644 --- a/pg-wasm/src/lib.rs +++ b/pg-wasm/src/lib.rs @@ -7,6 +7,7 @@ //! PostGuard wasm API. use pg_core::artifacts::{PublicKey, SigningKeyExt, UserSecretKey, VerifyingKey}; +use pg_core::challenge::{sign_challenge, verify_challenge}; use pg_core::client::web::stream::{StreamSealerConfig, StreamUnsealerConfig}; use pg_core::client::web::{SealerMemoryConfig, UnsealerMemoryConfig}; use pg_core::client::{Header, Sealer, Unsealer}; @@ -52,6 +53,14 @@ extern "C" { /// Seal options type from TypeScript. #[wasm_bindgen(typescript_type = "ISealOptions")] pub type ISealOptions; + + /// Signing key type from TypeScript. + #[wasm_bindgen(typescript_type = "ISigningKey")] + pub type ISigningKey; + + /// Policy type from TypeScript. + #[wasm_bindgen(typescript_type = "IPolicy")] + pub type IPolicy; } /// Seal options. @@ -132,6 +141,85 @@ pub fn js_is_canonical(atype: &str, value: &str) -> bool { pg_core::identity::is_canonical(atype, value) } +/// Signs a challenge chosen by a verifier, proving possession of a signing key +/// without revealing it. +/// +/// The signed message carries a domain separator that this function applies +/// itself, so the result can never double as a signature over a container +/// header. That is the point of the call: a verifier that could pick the whole +/// signed message would be able to have a header signed for a container it +/// wrote. +/// +/// # Arguments +/// +/// * `key` - The signing key to prove possession of, as `fetchKey("sign/key", ...)` returns it (see the README). +/// * `context` - What the proof is for, e.g. an endpoint or an upload id. +/// * `challenge` - The `Uint8Array` the verifier chose. +#[wasm_bindgen(js_name = signChallenge)] +pub fn js_sign_challenge( + key: ISigningKey, + context: &str, + challenge: Uint8Array, +) -> Result { + let mut rng = rand::thread_rng(); + + let key: SigningKeyExt = serde_wasm_bindgen::from_value(key.into())?; + let sig = sign_challenge(&key, context, &challenge.to_vec(), &mut rng); + let bytes = pg_core::bincode_compat::serialize(&sig).map_err(pg_core::error::Error::from)?; + + Ok(Uint8Array::from(bytes.as_slice())) +} + +/// Verifies a challenge signature against the identity the policy derives to. +/// +/// The identity is derived from the policy, which canonicalizes attribute +/// values, so this answers whether the signer holds the key for that identity +/// rather than whether its policy is spelled the same way. Use +/// [`js_canonicalize`] before keying anything on a raw attribute value. +/// +/// # Arguments +/// +/// * `vk` - The verifying key, can be obtained using, e.g. fetch(`{PKGURL}/v2/sign/parameters`). +/// * `pol` - The policy whose identity the signer should hold a key for. +/// * `context` - The same context the signature was requested under. +/// * `challenge` - The `Uint8Array` this verifier chose. +/// * `sig` - The signature as returned by [`js_sign_challenge`]. +/// +/// # Errors +/// +/// Errors when `vk` or `pol` cannot be read; those are the verifier's own +/// inputs. A `sig` that is not a well-formed signature is a failed proof, not +/// an error, and returns `false`. +#[wasm_bindgen(js_name = verifyChallenge)] +pub fn js_verify_challenge( + vk: JsValue, + pol: IPolicy, + context: &str, + challenge: Uint8Array, + sig: Uint8Array, +) -> Result { + let vk: VerifyingKey = serde_wasm_bindgen::from_value(vk)?; + let pol: Policy = serde_wasm_bindgen::from_value(pol.into())?; + + // Decoding stops at the end of the signature, so require the exact length + // as well: a caller must not be able to hang extra bytes off a valid proof. + if sig.length() as usize != pg_core::ibs::gg::SIG_BYTES { + return Ok(false); + } + + let Ok(sig) = pg_core::bincode_compat::deserialize(&sig.to_vec()) else { + return Ok(false); + }; + + Ok(verify_challenge( + &vk, + &pol, + context, + &challenge.to_vec(), + &sig, + )) +} + /// Seals the contents of a `Uint8Array` into a `Uint8Array` using /// the given master public key and policies. /// diff --git a/pg-wasm/tests/tests.rs b/pg-wasm/tests/tests.rs index 7f150453..3dc1d12f 100644 --- a/pg-wasm/tests/tests.rs +++ b/pg-wasm/tests/tests.rs @@ -732,3 +732,80 @@ mod legacy_containers { assert_eq!(&plain, PLAIN); } } + +/// The challenge exports are what lets a relay that holds only the verifying +/// key check that whoever is uploading holds the signing key for the identity +/// it reads out of a container. `pg-core` covers the crypto; these tests cover +/// the boundary the JS caller actually sees — the signature crossing as a +/// `Uint8Array` and coming back. +mod challenge { + use super::*; + + use pg_wasm::{js_sign_challenge, js_verify_challenge}; + + const CONTEXT: &str = "cryptify/upload"; + const CHALLENGE: &[u8] = b"a verifier-chosen challenge"; + + #[wasm_bindgen_test] + fn test_sign_verify_roundtrip() { + let mut rng = rand::thread_rng(); + let setup = TestSetup::new(&mut rng); + + let key = serde_wasm_bindgen::to_value(&setup.signing_keys[0]).unwrap(); + let pol = serde_wasm_bindgen::to_value(&setup.signing_keys[0].policy).unwrap(); + let vk = serde_wasm_bindgen::to_value(&setup.ibs_pk).unwrap(); + + let sig = js_sign_challenge(key.into(), CONTEXT, Uint8Array::from(CHALLENGE)) + .expect("sign the challenge"); + + assert_eq!(sig.length() as usize, pg_core::ibs::gg::SIG_BYTES); + + assert!( + js_verify_challenge(vk, pol.into(), CONTEXT, Uint8Array::from(CHALLENGE), sig) + .expect("verify the challenge") + ); + } + + #[wasm_bindgen_test] + fn test_verify_rejects_another_identity() { + let mut rng = rand::thread_rng(); + let setup = TestSetup::new(&mut rng); + + let key = serde_wasm_bindgen::to_value(&setup.signing_keys[0]).unwrap(); + let other = serde_wasm_bindgen::to_value(&setup.signing_keys[1].policy).unwrap(); + let vk = serde_wasm_bindgen::to_value(&setup.ibs_pk).unwrap(); + + let sig = js_sign_challenge(key.into(), CONTEXT, Uint8Array::from(CHALLENGE)) + .expect("sign the challenge"); + + assert!( + !js_verify_challenge(vk, other.into(), CONTEXT, Uint8Array::from(CHALLENGE), sig) + .expect("verify the challenge") + ); + } + + /// A signature the caller mangled is a failed proof, not a thrown error: JS + /// callers get `false` rather than an exception they have to catch. + #[wasm_bindgen_test] + fn test_verify_returns_false_for_a_malformed_signature() { + let mut rng = rand::thread_rng(); + let setup = TestSetup::new(&mut rng); + + let pol = serde_wasm_bindgen::to_value(&setup.signing_keys[0].policy).unwrap(); + let vk = serde_wasm_bindgen::to_value(&setup.ibs_pk).unwrap(); + + for len in [0u32, 1, (pg_core::ibs::gg::SIG_BYTES as u32) - 1] { + let vk = vk.clone(); + let pol = pol.clone(); + + assert!(!js_verify_challenge( + vk, + pol.into(), + CONTEXT, + Uint8Array::from(CHALLENGE), + Uint8Array::new_with_length(len), + ) + .expect("verify a signature of the wrong length")); + } + } +} From 3374e37b4f9d408bd0bc8f28268f7617443e0930 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:00:46 +0000 Subject: [PATCH 2/4] docs: the pg-wasm gates a workspace-root run does not cover `cargo fmt --all` from the root skips pg-wasm (it is on the root `exclude` list) and build.yml's fmt/clippy/test matrices do not list it either, so its clippy is red on main from pre-existing findings in its tests. Also note how to drive a pg-wasm export from node when the container has no browser for wasm-pack's headless tests. --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7d6687ec..6129bf60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,9 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the h - The `dobby-coder` GitHub App lacks `workflows: write` on this repo; any push touching `.github/workflows/*.yml` is rejected at the remote. Before treating a fix as blocked, check whether the same effect can be achieved in a pushable file (crate manifest, source, committed script); if a fix genuinely can only live in a workflow file, ship the pushable half and hand the maintainer ready-to-paste YAML in the PR body. The block covers *merge* commits too, which is easy to miss: once a branch carries its own `build.yml` change (typically a maintainer applying such a patch onto it), a later `git merge origin/main` that has to touch `build.yml` produces a commit updating a workflow file, and the push is rejected even when the resolution is only "keep both new jobs". Nothing can be split out of a merge commit, so that merge has to be landed by a maintainer, or the App needs `workflows: write`. Measured exception, worth trying before handing the sync over: the App pushed `ce0fc59` on this branch, a merge whose diff against its first parent added main's 64 new `build.yml` lines. That merge needed no resolution inside `build.yml` — it took main's side whole, so the blob it committed already existed in the repo. Try the merge and read the remote's answer; only escalate on an actual rejection. - **Consolidating a repo into a monorepo means transfer its open issues first, then archive — not archive-then-orphan.** `postguard-website`, `postguard-outlook-addon`, `postguard-tb-addon` and `postguard-examples` were archived (read-only) after folding into `postguard-js`, and the "open issues transfer here" step was silently skipped on all four: 62 open issues sat stranded, unworkable (`HTTP 403: Repository was archived so is read-only` on label/assign/comment/close). GitHub will not transfer an issue out of an already-archived repo, so recovering from the skip costs an unarchive → transfer → re-archive round trip per repo instead of a single transfer before archiving (decided in postguard#282). Do the transfer as part of the same change that archives the repo, and leave a "development moved to ``, see ``" banner at the top of the archived repo's README before re-archiving, so an old link still finds the new home. **Open PRs strand exactly the same way, and release automation manufactures them**, which is what `cryptify` hit (#294): `release-plz-pr` re-opened the identical `chore: release v0.1.28` PR (cryptify#205) ~2 minutes after the README-banner commit (`6411843a`) was pushed to main, because that push re-ran the release-plz job living in cryptify's `ci.yml` before the old repo's release-plz jobs had been retired (#293 moved the Docker publish and deliberately left them) — not, as it first looked, the close of the superseded PR (cryptify#161): that close and #205's opening were 3m06s apart, and closing a PR fires no CI. So closing the open PRs is not enough — retire whatever re-creates them first, in the same commit that finalizes the README, or the merge of that very commit re-opens one. The banner is also the last chance to correct the README: cryptify's still said "this repository's CI is still the only publisher of `ghcr.io/encryption4all/cryptify` — do not remove its build/push steps yet", false since #293, and unfixable once archived. Order that works: retire release automation + finalize banner in one PR → close the open PRs → transfer the issues → archive. +- **`pg-wasm` sits outside every per-crate gate the workspace has, so its checks have to be run by hand.** `cargo fmt --all` from the repo root formats workspace members only, and `pg-wasm` is on the root `exclude` list, so a root run reports clean while `cargo fmt --manifest-path pg-wasm/Cargo.toml --all -- --check` has diffs. The `fmt`/`clippy`/`test` matrices in `build.yml` are `[pg-core, pg-pkg, pg-cli, pg-ffi, cryptify]` — `pg-wasm` is in none of them; the only job that builds it is `wasm-pack test --release --headless` per browser, plus the semver gate. So `cargo clippy --manifest-path pg-wasm/Cargo.toml --target wasm32-unknown-unknown --all-targets -- -D warnings` is currently **red on `main`** (four pre-existing findings in `tests/helpers.rs` and `tests/tests.rs`, none in `src/`); do not read that as something a PR broke, and check `src/` separately with a plain `cargo clippy --manifest-path pg-wasm/Cargo.toml --target wasm32-unknown-unknown`, which is clean. +- **To try a `pg-wasm` export by hand without a browser, build the nodejs target.** `pg-wasm/tests/tests.rs` sets `wasm_bindgen_test_configure!(run_in_browser)` crate-wide, so those tests need `wasm-pack test --headless --chrome`, i.e. a real browser *and* a matching webdriver. Where there is no chromedriver, `wasm-pack build --dev --target nodejs --out-dir /tmp/pgwasm-node` (from inside `pg-wasm/`; `--dev` skips the `wasm-opt` download) produces a `require`-able package that exercises the same generated wasm-bindgen glue a browser would, so `node` can drive an export end to end. Key material for such a script comes out of `pg_core::test::TestSetup` through `serde_json` — human-readable, so artifacts serialize as base64 strings, which is the shape `serde_wasm_bindgen` hands the exports too. Also `cargo build --manifest-path pg-wasm/Cargo.toml --target wasm32-unknown-unknown --tests` compiles the browser tests without running them, which catches everything except the runtime behaviour. + ## Dependencies - postguard depends on `bincode-next` (crate name `bincode-next`, import `bincode_next`), a third-party fork by `panayang`/`Apich-Organization`, not the original `bincode-org`. Flag this trust caveat in any PR touching it. Pin the exact rc in use and re-audit on any rc bump (current: `bincode-next 3.0.0-rc.14`). `bincode_next::config::legacy()` is byte-compatible with bincode 1.x (pinned by a regression test); use it wherever wire/on-disk format matters. The error type split (`EncodeError`/`DecodeError`) is a breaking API change requiring a version bump of every in-repo dependent. A `bincode-next` rc bump may raise its MSRV, which can force the Docker Rust pin higher too; check before bumping either. From ebd9065ff4003797f722401cf6c21adcb0207a48 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:15:34 +0000 Subject: [PATCH 3/4] test(pg-wasm): cover the undecodable-signature branch of verifyChallenge The malformed-signature test only fed wrong-length arrays, which stop at the length check and never reach the decode. All-zero bytes would not reach it either: those decode into a signature that merely fails to verify. 0xff-filled bytes are the right length and do not decode, so they are what exercises that branch returning false rather than throwing. --- pg-wasm/tests/tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pg-wasm/tests/tests.rs b/pg-wasm/tests/tests.rs index 3dc1d12f..1318409f 100644 --- a/pg-wasm/tests/tests.rs +++ b/pg-wasm/tests/tests.rs @@ -807,5 +807,19 @@ mod challenge { ) .expect("verify a signature of the wrong length")); } + + // The right length but not a decodable signature, which reaches the + // decode branch instead of stopping at the length check. All-zero + // bytes would not do: those decode fine and merely fail to verify. + let undecodable = vec![0xffu8; pg_core::ibs::gg::SIG_BYTES]; + + assert!(!js_verify_challenge( + vk, + pol.into(), + CONTEXT, + Uint8Array::from(CHALLENGE), + Uint8Array::from(undecodable.as_slice()), + ) + .expect("verify an undecodable signature")); } } From 1810416694f501cd0599e449f79884a1904492e4 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:16:52 +0000 Subject: [PATCH 4/4] docs: the semver gate's pinned installer is x86_64-only Running scripts/semver-checks.sh locally on an arm64 host fails as a qemu loader error, which looks like a corrupt download rather than the wrong architecture. Name the aarch64 asset and the skip-count difference so the next run does not re-diagnose it. --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6129bf60..d613c292 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the h - **Identity attribute values are canonicalized before they are hashed, and the canonical form is Yivi's, not ours** (#250). `Policy::derive` hashes exact value bytes and the PKG builds its `con` from Yivi's `raw_value` verbatim (`pg-pkg/src/middleware/auth.rs`), so a sender's policy only decrypts when its bytes match the disclosure exactly — which is why a capitalized email silently produced an undecryptable container. `pg-core/src/identity.rs` now carries `canonicalize`/`is_canonical` and a `RULES` table (email: trim + lowercase; mobile: drop grouping separators and a `(0)` trunk group, map a leading `00` to `+`). Four things about it are load-bearing. (1) **Both sides apply the same function, so canonicalization is a coarsening of equality** — it can only merge identities that used to differ, never split a pair that already matched, which is why this is a fix rather than a break and why no working container regressed. The one exception is **version skew**, and **no deploy order fixes it** — the obvious "PKG first" rule is wrong. Both directions break on exactly the same condition: a PKG-side value `D` for which `canon(D) != D`. New client + old PKG needs `canon(S) == D`, old client + new PKG needs `S == canon(D)`, and both held before only because `S == D`. Yivi's disclosures are canonical by premise so the IRMA/JWT path has no exposure, but the **API-key path builds its conjunction from hand-entered business-portal fields** (`pg-pkg/src/middleware/auth.rs`, `key_data.email` / `key_data.phone_number`, the latter typed `pbdf.sidn-pbdf.mobilenumber.mobilenumber`, which carries a rule), so a non-canonical `D` is ordinary there. The exposure is transient — it clears once both halves are deployed — and the durable fix is canonicalizing the stored portal data, not sequencing. The **signing** path is the one that *was* order-dependent and is now order-free: `pg-pkg/src/handlers/signing_key.rs` canonicalizes the policy it **returns**, not just the one it derives from, so the returned policy is a fixed point every client and verifier version agrees on. Getting that wrong is subtle and shipped in the first cut of #250 — `derive_ibs` canonicalizes internally, so a handler that returns the raw policy pairs a key for `derive(canon(pol))` with header bytes reading `pol`, and an un-upgraded verifier rejects signatures it accepts today. (2) It runs in **four** places on purpose — `Header::new` for recipient policies, `canonical_signing_key` for the client-side signing policy, `pg-pkg`'s `signing_key.rs` for the policy the PKG hands back, and defensively inside `derive` itself — because the stored policy is what an *un-upgraded* verifier reads back out of the header (`h_sig_ext.pol`), while `derive` is what catches a policy built by hand or assembled by the PKG from a disclosure. That triple application is why **idempotence is a correctness requirement, not a nicety**, and every vector asserts it. (3) The registry is keyed on the type's **tail** (`sidn-pbdf.email.email`), not the full literal, so `pbdf.`, `irma-demo.` and any future scheme match one entry — `HINT_TYPES` in the same file is keyed on full literals and is *missing* its `irma-demo` email row for exactly that reason. Note `test.test.email` (postguard-e2e's keyshare flow) deliberately does not match. (4) `pg-core` is `#![no_std]`, so a national number → E.164 (`0612345678` → `+31612345678`) is **not** implementable here: it needs libphonenumber's metadata database and a country hint. `canonicalize` is total and passes such a value through untouched; `is_canonical` is what reports it, and tb-addon's `libphonenumber-js/mobile` must stay. **The wire-compat gate is blind to all of this** — every value in `pg-core/examples/seal-samples/sample_set.rs` is already canonical, so the whole corpus is a no-op and the gate stays green whichever way the design went; a non-canonical sample would be needed to give it teeth (deliberately not added, see #250's resolution). - CI's `Format workspace` matrix runs `cargo fmt --manifest-path /Cargo.toml --all -- --check` once per member directory over shared workspace files; always run `cargo fmt --all -- --check` from repo root before pushing, or one crate's drift fails the whole matrix. - `Run wasm tests in browsers` flakes, and the error names the wrong culprit. `Error: missing field 'chunk'` is `wasm-bindgen-test-runner` failing to parse a truncated webdriver reply; the cause is the line above it, `[SEVERE]: Timed out receiving message from renderer: 30.000`. Read the driver stderr before suspecting the test. The matrix is fail-fast, so one browser timing out reports the other two as failures when they were cancelled: check each job's own conclusion, not the summary. Seen on the same sha passing at 07:45 and failing at 07:48 (runs 30432815599 and 30432995207 on #269, a docs-only commit). Re-run rather than debug, and note that `dobby-coder` cannot: `POST /actions/runs/{id}/rerun-failed-jobs` is 403 for the App (`Resource not accessible by integration`), so a maintainer has to click it, or a fresh push has to supersede the run. `delivery.yml`'s GHCR jobs flake the same way and are worth the same treatment: every one of them starts with a `Log in to GHCR` step, and that step alone has failed twice on #347's PR, once as `denied: denied` and once as `Get "https://ghcr.io/v2/": net/http: request canceled while waiting for connection`, on two different jobs (`Scan cryptify image`, then `Finalize cryptify manifest`) and with the other passing. It is the registry, not the diff — check whether the same job is green on the last few `main` runs before reading a red `delivery.yml` check as yours, and note that a pg-core-only diff cannot reach a Docker job at all. None of these are required contexts; `Wire compat` is. -- `scripts/semver-checks.sh` runs `cargo-semver-checks` over the two surfaces external consumers build against: `pg-core` against its crates.io release, and `pg-wasm` against `origin/main` (it has no crates.io release; the npm package is versioned from `pg-core`). The `semver-checks` job in `build.yml` calls it on any PR touching `pg-core`, `pg-wasm`, the root manifest or the script itself; run it yourself too before pushing such a change, since the job needs a wasm32 toolchain and a pinned cargo-semver-checks download and is therefore not the fastest feedback. Four things it encodes. (1) `pg-core` needs `--only-explicit-features --features test,rust,stream`, the same set the test and clippy matrices use: cargo-semver-checks otherwise enables everything that doesn't look unstable, which pulls in `web` and hits its `compile_error!`. (2) `pg-core`'s `web,stream` surface is deliberately not checked. `Unsealer` has two `unseal` methods there on different instantiations (owned `self` in `client/web/mod.rs`, `&mut self` in `client/web/stream.rs`) and cargo-semver-checks 0.49 pairs them by name alone, so it reports `method_receiver_mut_ref_became_owned` against byte-identical source; `rust,stream` is clean because both receivers are owned there. (3) Any wasm32 run needs `RUSTFLAGS=--cap-lints=warn`, because the `--cap-lints allow` cargo-semver-checks sets silences the "dropping unsupported crate type" warnings cargo reads back when probing rustc, and cargo then dies with "output of --print=file-names missing". (4) `cargo-semver-checks` splits its non-zero exits: `100` is a semver violation, `101` is the tool or the build failing (unresolvable baseline rev, missing rustup target, registry fetch failure, compile error in the crate). Never treat "non-zero" as "breaking change" here, because the advice a semver gate prints is "declare the break", and on this repo that means a `!` in the PR title and a spurious major release of `pg-core`. `scripts/semver-checks-test.sh` pins that mapping; it stubs `cargo`, so it runs in well under a second and needs neither cargo-semver-checks nor a wasm32 toolchain. Run it after touching the gate. +- `scripts/semver-checks.sh` runs `cargo-semver-checks` over the two surfaces external consumers build against: `pg-core` against its crates.io release, and `pg-wasm` against `origin/main` (it has no crates.io release; the npm package is versioned from `pg-core`). The `semver-checks` job in `build.yml` calls it on any PR touching `pg-core`, `pg-wasm`, the root manifest or the script itself; run it yourself too before pushing such a change, since the job needs a wasm32 toolchain and a pinned cargo-semver-checks download and is therefore not the fastest feedback. Four things it encodes. (1) `pg-core` needs `--only-explicit-features --features test,rust,stream`, the same set the test and clippy matrices use: cargo-semver-checks otherwise enables everything that doesn't look unstable, which pulls in `web` and hits its `compile_error!`. (2) `pg-core`'s `web,stream` surface is deliberately not checked. `Unsealer` has two `unseal` methods there on different instantiations (owned `self` in `client/web/mod.rs`, `&mut self` in `client/web/stream.rs`) and cargo-semver-checks 0.49 pairs them by name alone, so it reports `method_receiver_mut_ref_became_owned` against byte-identical source; `rust,stream` is clean because both receivers are owned there. (3) Any wasm32 run needs `RUSTFLAGS=--cap-lints=warn`, because the `--cap-lints allow` cargo-semver-checks sets silences the "dropping unsupported crate type" warnings cargo reads back when probing rustc, and cargo then dies with "output of --print=file-names missing". (4) `cargo-semver-checks` splits its non-zero exits: `100` is a semver violation, `101` is the tool or the build failing (unresolvable baseline rev, missing rustup target, registry fetch failure, compile error in the crate). Never treat "non-zero" as "breaking change" here, because the advice a semver gate prints is "declare the break", and on this repo that means a `!` in the PR title and a spurious major release of `pg-core`. `scripts/semver-checks-test.sh` pins that mapping; it stubs `cargo`, so it runs in well under a second and needs neither cargo-semver-checks nor a wasm32 toolchain. Run it after touching the gate. To run the real gate locally, note that `build.yml`'s pinned install step fetches the **x86_64** tarball and its `SHA256` covers only that one; on an arm64 host it unpacks fine and then dies as `qemu-x86_64: Could not open '/lib64/ld-linux-x86-64.so.2'`, which reads like a broken download rather than the wrong architecture. Take `cargo-semver-checks-aarch64-unknown-linux-gnu.tar.gz` from the same release instead, and expect a skip count one lower than CI's (57, not 58). - release-plz owns the version numbers, so the PR making a breaking change cannot bump the crate to match (bumping `pg-core` alone doesn't even resolve: `pg-cli` requires `^0.6.1`). What the semver gate accepts as the declaration is the conventional-commit `!` in the PR title, and only that; CI turns it into `SEMVER_RELEASE_TYPE=major`, which the script passes as `--release-type major`. A `BREAKING CHANGE:` footer in the PR body is not accepted and must not be: this repo's `squash_merge_commit_message` is `COMMIT_MESSAGES`, so the body never reaches the squashed commit, and release-plz reading a bare `fix(pg-core):` subject would cut a patch release of a break the gate had already waved through. Two consequences of the merge settings worth knowing when you declare a break. `squash_merge_commit_title` is `COMMIT_OR_PR_TITLE`, which is the PR title on a multi-commit PR but the commit's subject when the PR has exactly one commit — so on a single-commit PR put the `!` in the commit subject too, or the gate goes green off the PR title while release-plz cuts a patch. And `--release-type major` doesn't merely permit a bigger bump: every lint exists to demand a bump the declaration already grants, so all of them skip and the run checks nothing (`0 checks: 0 pass, 253 skip`) on both surfaces at once. A green gate on a `!` PR verified nothing; a `!` added for a pg-wasm break also passes any unrelated pg-core break in the same PR. - There are **two** Docker builds, `Dockerfile` (pg-pkg) and `cryptify/Dockerfile`, each with its own image name and independent version output in `delivery.yml`. cryptify's builds with the **repo root** as context (`file: cryptify/Dockerfile`), because the crate is a workspace member and needs the root manifest and lockfile; it claims the same `ghcr.io//cryptify` name the old repo published, so nothing downstream has to repoint. `build-cryptify`, `scan-cryptify` and `finalize-cryptify` publish unconditionally as of 2026-08-07 (postguard#293): the `if: vars.PUBLISH_CRYPTIFY_IMAGE == 'true'` gate was removed once the existing GHCR package granted this repo Write, so this pipeline is now the **sole** publisher of `ghcr.io/encryption4all/cryptify`. The race is over: `encryption4all/cryptify`'s own build/push jobs were retired in cryptify#206 (merged 2026-08-07T14:00:28Z, the other half of #293), its release-plz jobs in cryptify#207 (merged 2026-08-09T12:24:06Z), and the repo is archived as of 2026-08-09 (#294) — its jobs are gone and an archived repo runs none, so nothing else can push that name. Archiving the *source* repo of a GHCR package does not by itself revoke the Write grant this repo holds on it: `Delivery` run 31313300399 (created 2026-08-09T12:26:46Z, on this PR's `1e5183fe`, with `encryption4all/cryptify` already archived) shows `Build cryptify (amd64)`/`(arm64)` passing and `Finalize cryptify manifest` pushing the **tag** `ghcr.io/encryption4all/cryptify:pr-311` at 12:28:13Z — the tag push, not just the by-digest push, is what proves tagging still works under an archived source repo. Both Dockerfiles pin the same Rust today (`FROM rust:1.96.1-slim-trixie`), which is older or otherwise different from the `Test workspace`/`Format workspace` jobs' `dtolnay/rust-toolchain@stable`. A change can pass every workspace test and still fail a Docker Build on a type-inference difference that doesn't reproduce on host stable (e.g. a slice-element-type unification difference across rustc versions). Check the current pins, and run `cargo build --profile edge --bin pg-pkg` locally before pushing any `Cargo.toml` dependency bump; for a true repro, build the image. - **No CI job in this repo builds `dev.Dockerfile`, so it breaks silently.** `delivery.yml` builds `Dockerfile` and `cryptify/Dockerfile` only; `dev.Dockerfile` is built by the root `docker-compose.yml` (local dev) and by `postguard-e2e`'s stack. That is how #277 could add `cryptify` to `members`, patch the other two Dockerfiles and miss this one, with the break surfacing four days later as a Docker error in a *different* repo's nightly `e2e` run rather than on the PR (#322). **All four** Dockerfiles build with the **repo root** as context — `Dockerfile`, `dev.Dockerfile`, `cryptify/Dockerfile`, `cryptify/dev.Dockerfile` — and each has to copy every member of the root `Cargo.toml`, because `cargo chef prepare` shells out to `cargo metadata`, which loads every member's manifest before any target selection; a member it cannot read is `error: failed to load manifest for workspace member`, even for an image that never compiles it. `cryptify/dev.Dockerfile` was the exception until #325: it had kept the standalone layout the crate had before #277, copying its own `Cargo.toml`/`Cargo.lock`/`src`, which described nothing real — a workspace member has no `cryptify/Cargo.lock`, and cryptify's `pg-core = { path = "../pg-core" }` escapes a crate-directory context. Nothing in this repo builds it even now: `cryptify/docker-compose.dev.yml` and `cryptify/docker-compose.yml` still name `backend.dev.Dockerfile`/`backend.Dockerfile`, which #277 did not carry over, so treat both cryptify compose files as unverified until someone repoints and runs them. Two things to fix when someone does: the volume mounts have to move from `/app/src` to `/app/cryptify/src` (`/app` is the workspace root now), and `conf/config.dev.toml` points `pkg_url` at `http://postguard-pkg:8087`, a service neither cryptify compose file defines — cryptify blocks in `try_fetch_verifying_key` at startup and then panics, so the container comes up and never listens. `pg-pkg/tests/dockerfile_workspace_members.rs` asserts the root-context invariant on every PR (the `Test workspace (pg-pkg)` job), which is cheaper than building the images; adding a workspace member, or a Dockerfile anywhere in the tree, fails it until both lists are updated. The invariant is **per stage**, not per file: each stage starts from its parent image's filesystem, and `Dockerfile` and `cryptify/Dockerfile` copy the members twice, once for `cargo chef prepare` in the planner and once for `cargo build` in the builder — so patching only the planner leaves the builder dying on the same error, and the guard checks every stage that copies at least one member **or the root `Cargo.toml`**. That second half went in with #325: `cryptify/dev.Dockerfile` copied a manifest and a lockfile and no member at all, so "copies no member, therefore not a source-copying stage" waved it straight through — a stage that copies the workspace manifest is standing the workspace up, and cargo reads every member that manifest lists. When simulating a build context by hand, note that `.dockerignore` patterns match against the **context-root-relative path** and `*` does not cross `/`: the root file's `target`, `img` and `*.md` drop root-level entries only, so `pg-core/README.md` does reach the build — which it must, because `pg-core/src/lib.rs` opens with `#![doc = include_str!("../README.md")]` and rustc evaluates that on a plain `cargo build`. Excluding `*.md` recursively in a hand-built context fails with `couldn't read pg-core/src/../README.md`, which looks like a repo bug and is not one. Both halves of that guard match `FROM`/`COPY` **case-insensitively**, because Docker's instruction keywords are: a lower-case Dockerfile would otherwise parse as zero stages, and a zero-stage file makes every assertion over its stages vacuously true rather than red. Neither site is optional — with only the `FROM` match fixed, no lower-case `copy` counts, so every member reads as missing and the "a stage that copies no member is not a source-copying stage" escape skips the stage anyway. Also scope every `cargo chef cook` to the binary its image runs (`--bin pg-pkg`, `--bin cryptify`): the recipe covers the whole workspace, so an unscoped cook drags cryptify's rocket/lettre/`rusqlite` (`bundled`, i.e. the SQLite amalgamation) tree — 106 crates unreachable from `pg-pkg` — into an image that never runs them.