feat(pg-core): challenge-signature API for proving possession of a signing key - #368
Draft
dobby-coder[bot] wants to merge 4 commits into
Draft
feat(pg-core): challenge-signature API for proving possession of a signing key#368dobby-coder[bot] wants to merge 4 commits into
dobby-coder[bot] wants to merge 4 commits into
Conversation
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
Bot
force-pushed
the
feat/362-challenge-signature
branch
from
August 18, 2026 14:01
ad8ed6b to
3374e37
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) -> Signatureverify_challenge(vk, pol, context, challenge, sig) -> boolpg-wasmexports both assignChallenge/verifyChallenge, challenge and signature crossing asUint8Array. No existing export changed.Both halves build the signed message through one private helper:
The separator is prepended inside
sign_challengeand 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 validh_sig_extfor 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:A signature made for
(context="ab", challenge="c")verifies as(context="a", challenge="bc"), because both concatenate topostguard/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:TestSetupidentity;Alice@Example.COMverifies under a key issued foralice@example.com, becausederive_ibscanonicalizes. Asserted rather than left implicit: a consumer keying on the raw attribute value has to canonicalize it itself, the proof does not pin spelling;CHALLENGE_DOMAINexists for, so it gets a test rather than a comment.Four more in
pg-wasm/tests/tests.rsfor the JS boundary: roundtrip, wrong identity, a signature of the wrong length returningfalseinstead 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
The semver gate reports
no semver update requiredfor bothpg-coreandpg-wasm, exit 0, so nothing here is a break and the title needs no!. Onpg-wasmthat is against theorigin/mainbaseline the script clones, which is what CI compares too.The
pg-wasmbrowser 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 fromnode: 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
nodedrive extended to cover the undecodable signature the new test asserts on.Notes for review
pg_core::challenge, not underclient. A challenge proof is not sealing or unsealing, and a top-level module needs neither therustnor thewebfeature, so cryptify (rust) and pg-wasm (web) reach it the same way.bincode_compatlegacy, the same encodingSigningKeyand the header signature use, and it is exactly 96 bytes (ibs::gg::SIG_BYTES).verifyChallengerequires that length: decoding stops at the end of the signature, so without the check a caller could hang arbitrary bytes off a valid proof.verifyChallengeerrors onvkandpolbut not onsig. 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 returnsfalse; a JS caller does not need atry/catcharound attacker-controlled bytes.!.CLAUDE.mdgains twopg-wasmnotes: a rootcargo fmt --alldoes not cover it (it is on the rootexcludelist) and it is in none ofbuild.yml's per-crate matrices, socargo clippy --all-targetson it is red onmainfrom four pre-existing findings in its tests; and how to drive an export fromnodewhen there is no browser.pg-core/examples/seal-samples/,.github/workflows/. No new dependency.