Skip to content

feat(pg-core): challenge-signature API for proving possession of a signing key - #368

Draft
dobby-coder[bot] wants to merge 4 commits into
mainfrom
feat/362-challenge-signature
Draft

feat(pg-core): challenge-signature API for proving possession of a signing key#368
dobby-coder[bot] wants to merge 4 commits into
mainfrom
feat/362-challenge-signature

Conversation

@dobby-coder

@dobby-coder dobby-coder Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes #362. Part of #338.

A holder of a PKG-issued signing key can now prove possession of it to a party that holds only the verifying key. cryptify relays containers it cannot open, reads the sender identity out of the header, and today has no way to tell whether the uploader is that sender; a signature over a server-chosen challenge is something it can check locally. This PR is the crypto surface only, no cryptify or pg-js half.

What is here

pg-core::challenge, a new module:

  • CHALLENGE_DOMAIN: the domain separator, b"postguard/challenge/v1".
  • sign_challenge(key, context, challenge, rng) -> Signature
  • verify_challenge(vk, pol, context, challenge, sig) -> bool

pg-wasm exports both as signChallenge / verifyChallenge, challenge and signature crossing as Uint8Array. No existing export changed.

Both halves build the signed message through one private helper:

CHALLENGE_DOMAIN || (context.len() as u64).to_be_bytes() || context
                 || (challenge.len() as u64).to_be_bytes() || challenge

The separator is prepended inside sign_challenge and is not an argument, so no caller can opt out of it: the same key signs container headers, and a verifier that could choose the whole signed message could hand over a header dressed as a challenge and get back a valid h_sig_ext for a container it wrote.

Test 5 shown red

The issue asks for proof that the length prefixes are load-bearing. Removing them from challenge_message, so the parts concatenate raw:

$ cargo test --manifest-path pg-core/Cargo.toml --features test,rust,stream --lib challenge
running 7 tests
test challenge::tests::test_challenge_split_is_unambiguous ... FAILED
...
---- challenge::tests::test_challenge_split_is_unambiguous stdout ----
thread 'challenge::tests::test_challenge_split_is_unambiguous' panicked at pg-core/src/challenge.rs:222:9:
assertion failed: !verify_challenge(&setup.ibs_pk, &key.policy, "a", b"bc", &sig)

test result: FAILED. 6 passed; 1 failed

A signature made for (context="ab", challenge="c") verifies as (context="a", challenge="bc"), because both concatenate to postguard/challenge/v1abc. With the prefixes back, all 7 pass. The test also asserts the two messages differ byte for byte, which checks the same claim on the bytes directly.

Tests

Seven unit tests in pg-core/src/challenge.rs, the six the issue lists plus one more:

  1. roundtrip under the signer's own identity;
  2. fails under a second TestSetup identity;
  3. fails against another challenge;
  4. fails against another context;
  5. the ambiguity test above;
  6. a policy spelling the address Alice@Example.COM verifies under a key issued for alice@example.com, because derive_ibs canonicalizes. Asserted rather than left implicit: a consumer keying on the raw attribute value has to canonicalize it itself, the proof does not pin spelling;
  7. added: a signature over the bare challenge, made the way the header path makes one, does not verify as a challenge proof. This is the property CHALLENGE_DOMAIN exists for, so it gets a test rather than a comment.

Four more in pg-wasm/tests/tests.rs for the JS boundary: roundtrip, wrong identity, a signature of the wrong length returning false instead of throwing, and a signature of the right length that does not decode. The last one is a separate case because it is a separate branch: wrong-length input stops at the length check, and all-zero bytes decode into a signature that merely fails to verify, so neither reaches the decode.

Ran

cargo test --manifest-path pg-core/Cargo.toml --features test,rust,stream   # 94 pass over 5 targets
cargo test --workspace                                                      # 358 pass over 12 targets
cargo fmt --all -- --check                                                  # clean
cargo fmt --manifest-path pg-wasm/Cargo.toml --all -- --check               # clean
cargo clippy --manifest-path pg-core/Cargo.toml --all-targets --features test,rust,stream -- -D warnings   # clean
cargo clippy --manifest-path pg-wasm/Cargo.toml --target wasm32-unknown-unknown -- -D warnings              # clean
SEMVER_RELEASE_TYPE=minor ./scripts/semver-checks.sh                        # 196 pass, 57 skip on each surface

The semver gate reports no semver update required for both pg-core and pg-wasm, exit 0, so nothing here is a break and the title needs no !. On pg-wasm that is against the origin/main baseline the script clones, which is what CI compares too.

The pg-wasm browser tests could not run here, no webdriver in the container, so they are covered by CI. To check the exports actually work rather than only compile, I built the nodejs target and drove them from node: sign in JS then verify in Rust, and sign in Rust then verify in JS, both true, plus wrong identity, wrong challenge, wrong context, flipped byte, truncated and padded signature all false, and the non-canonical policy true. That is the same set the committed tests cover, run through the generated wasm-bindgen glue.

Re-verified on the final tree: every command above rerun green, the ambiguity test shown red again with the prefixes removed and green with them back, and the node drive extended to cover the undecodable signature the new test asserts on.

Notes for review

  • The module sits at top level, pg_core::challenge, not under client. A challenge proof is not sealing or unsealing, and a top-level module needs neither the rust nor the web feature, so cryptify (rust) and pg-wasm (web) reach it the same way.
  • The signature encoding across the wasm boundary is bincode_compat legacy, the same encoding SigningKey and the header signature use, and it is exactly 96 bytes (ibs::gg::SIG_BYTES). verifyChallenge requires that length: decoding stops at the end of the signature, so without the check a caller could hang arbitrary bytes off a valid proof.
  • verifyChallenge errors on vk and pol but not on sig. The first two are the verifier's own inputs, so a bad one is a bug it should hear about. The signature is the untrusted half, so a malformed one is a failed proof and returns false; a JS caller does not need a try/catch around attacker-controlled bytes.
  • All items are new, so this is a minor bump and the title carries no !.
  • CLAUDE.md gains two pg-wasm notes: a root cargo fmt --all does not cover it (it is on the root exclude list) and it is in none of build.yml's per-crate matrices, so cargo clippy --all-targets on it is red on main from four pre-existing findings in its tests; and how to drive an export from node when there is no browser.
  • Untouched, per the scope fence: sealer and unsealer paths, header format, wire layout, pg-core/examples/seal-samples/, .github/workflows/. No new dependency.

@dobby-coder
dobby-coder Bot requested a review from rubenhensen August 18, 2026 13:59
dobby-coder Bot added 2 commits August 18, 2026 14:00
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.
`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.
@dobby-coder
dobby-coder Bot force-pushed the feat/362-challenge-signature branch from ad8ed6b to 3374e37 Compare August 18, 2026 14:01
dobby-coder Bot added 2 commits August 18, 2026 14:15
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pg-core, pg-wasm: a challenge-signature API for proving possession of a signing key

0 participants