Skip to content

feat(pq-threshold-seal): add cross-language threshold envelopes - #34

Open
exocognosis wants to merge 1 commit into
multivmlabs:mainfrom
exocognosis:agent/pq-threshold-seal
Open

feat(pq-threshold-seal): add cross-language threshold envelopes#34
exocognosis wants to merge 1 commit into
multivmlabs:mainfrom
exocognosis:agent/pq-threshold-seal

Conversation

@exocognosis

Copy link
Copy Markdown

Summary

Adds pq-threshold-seal, an experimental Rust and TypeScript package for sealing one byte payload to an ordered ML-KEM-768 committee with k-of-n recovery.

The implementation encrypts the payload once with a random ChaCha20-Poly1305 key, splits that key with GF(256) Shamir secret sharing, and wraps one share for each recipient using a key derived from an ML-KEM-768 shared secret. This keeps body encryption independent of committee size while limiting each recipient record to one ML-KEM ciphertext and one encrypted 32-byte share.

Package(s)

  • pq-threshold-seal
  • root Rust and TypeScript workspace registrations
  • root package catalog

Languages

  • TypeScript
  • Rust

Proposed solution

Threshold sealing

  • Generate one random 32-byte body key.
  • Split the key into n shares using independent Shamir polynomials over the Rijndael GF(256) field.
  • Require any k distinct shares to reconstruct the body key.
  • Encrypt the body with ChaCha20-Poly1305.
  • Encapsulate an ML-KEM-768 shared secret for every ordered recipient.
  • Derive a dedicated wrapping key for each recipient with SHAKE256.
  • Encrypt each 32-byte share with ChaCha20-Poly1305.

Context and roster binding

The caller supplies a domain, session identifier, and ordered roster hash. The package uses a canonical length-prefixed context in the key commitment, wrapping-key derivation, nonce derivation, and authenticated data. Recipient indexes, threshold parameters, the body-key commitment, and each ML-KEM ciphertext are also authenticated.

This makes envelopes specific to their protocol domain, session, threshold, recipient position, and roster. Applications remain responsible for authenticating recipient public keys, calculating the ordered roster hash, issuing unique session identifiers, and enforcing replay policy.

Canonical envelope format

The new PQTS version 1 format contains:

  • a fixed magic value and version
  • the k and n threshold values
  • a 32-byte SHAKE256 body-key commitment
  • a bounded length-prefixed body ciphertext
  • n fixed-size recipient records in ascending one-based order

Decoders reject unknown versions, trailing bytes, invalid thresholds, noncanonical recipient order, wrong field lengths, short authentication tags, and body ciphertexts over 16 MiB.

Cross-language consistency

Rust and TypeScript expose equivalent sealing, share recovery, body reconstruction, and envelope codec APIs. A shared deterministic vector supplies:

  • ML-KEM key-generation seeds
  • the complete sealing randomness stream
  • plaintext and binding context
  • the expected envelope length
  • the expected body-key commitment
  • the expected SHA-256 fingerprint of the canonical envelope

Both implementations reproduce the same 3,503-byte envelope fingerprint and recover the same plaintext from recipients 1 and 3.

Key validation and secret handling

  • Rust and TypeScript reject noncanonical ML-KEM public-key coefficients.
  • Both reject decapsulation keys whose embedded public-key hash is corrupt.
  • Rust supports the repository's Rust 1.78 minimum and builds without default features.
  • Sensitive body keys, Shamir coefficients, encapsulation messages, shared secrets, and wrapping keys are cleared after use where supported by the language runtime.
  • The composition is documented as experimental and unaudited.

Validation

  • cargo +1.78.0 clippy --manifest-path packages/pq-threshold-seal/rust/Cargo.toml --all-targets --all-features -- -D warnings
  • cargo +1.78.0 test --manifest-path packages/pq-threshold-seal/rust/Cargo.toml --all-features
  • cargo +1.78.0 build --manifest-path packages/pq-threshold-seal/rust/Cargo.toml --no-default-features
  • TypeScript Biome checks for source, tests, and configuration
  • bun run --cwd packages/pq-threshold-seal/ts build
  • bun test packages/pq-threshold-seal/ts/tests
  • cargo package -p pq-threshold-seal --allow-dirty
  • npm pack --dry-run --workspace pq-threshold-seal

The Rust and TypeScript suites cover round trips, canonical encoding, shared-vector compatibility, wrong context, duplicate shares, body tampering, malformed thresholds, malformed envelopes, noncanonical public keys, and corrupt private keys.

Checklist

  • Tests pass for all modified packages
  • Linting and formatting pass
  • Both language implementations are consistent
  • Package READMEs and protocol specification added
  • Dependencies are limited to the required ML-KEM, SHAKE256, ChaCha20-Poly1305, randomness, and secret-clearing implementations

Related Issues

Fixes #32

@exocognosis
exocognosis marked this pull request as ready for review July 25, 2026 02:46
@exocognosis
exocognosis requested a review from a team as a code owner July 25, 2026 02:46
@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown

Greptile Summary

Adds an experimental cross-language threshold-sealing package.

  • Implements compatible Rust and TypeScript ML-KEM, Shamir sharing, ChaCha20-Poly1305, and canonical envelope APIs.
  • Adds shared deterministic vectors, malformed-input and round-trip tests, protocol documentation, and workspace registrations.
  • Updates Rust and Bun lockfiles for the new cryptographic dependencies.

Confidence Score: 4/5

The PR appears safe to merge, with non-blocking secret-cleanup and TypeScript declaration consistency issues to address.

The threshold arithmetic, canonical codec, and cross-language derivations are aligned, while two temporary-secret paths retain sensitive material longer than intended and the exported TypeScript shapes diverge from repository convention.

Files Needing Attention: packages/pq-threshold-seal/ts/src/index.ts; packages/pq-threshold-seal/rust/src/lib.rs

Important Files Changed

Filename Overview
packages/pq-threshold-seal/rust/src/lib.rs Implements the Rust protocol and codec correctly overall, but skips KEK zeroization when share authentication fails.
packages/pq-threshold-seal/ts/src/index.ts Implements the matching TypeScript protocol, but retains Shamir coefficients and uses interfaces contrary to repository conventions.
packages/pq-threshold-seal/SPEC.md Defines the canonical context, derivations, envelope format, and application security responsibilities.
packages/pq-threshold-seal/ts/tests/index.test.ts Covers round trips, shared vectors, malformed envelopes, tampering, and malformed ML-KEM keys.
packages/pq-threshold-seal/rust/Cargo.toml Registers the no_std-compatible Rust package and required cryptographic dependencies.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/pq-threshold-seal/ts/src/index.ts:549-563
**Shamir coefficients remain uncleared**

`splitSecret` allocates the random polynomial coefficients but never overwrites them; only the generated shares are cleared by the caller. This leaves sensitive material capable of deriving every share in JavaScript heap memory until garbage collection.

### Issue 2 of 3
packages/pq-threshold-seal/rust/src/lib.rs:297-298
**Authentication failures retain the KEK**

The `?` on `decrypt` returns before `kek.zeroize()` when a tampered share or wrong context fails authentication. As a result, this expected failure path leaves the derived wrapping key in stack memory rather than clearing it.

### Issue 3 of 3
packages/pq-threshold-seal/ts/src/index.ts:28-35
**Plain data shapes use interfaces**

`BindingContext` and the other exported DTO-like structures are plain readonly data shapes without extension or implementation requirements. Define these as type aliases to follow the repository's TypeScript convention consistently.

Reviews (1): Last reviewed commit: "feat(pq-threshold-seal): add cross-langu..." | Re-trigger Greptile

Comment on lines +549 to +563
function splitSecret(secret: Uint8Array, threshold: Threshold, rng: RandomBytes): Uint8Array[] {
const coefficients = Array.from({ length: threshold.k - 1 }, () => takeRandom(rng, KEY_SIZE));
return Array.from({ length: threshold.n }, (_, position) => {
const index = position + 1;
const value = Uint8Array.from(secret);
for (let byte = 0; byte < KEY_SIZE; byte += 1) {
let power = index;
for (const coefficient of coefficients) {
value[byte] ^= gfMultiply(coefficient[byte], power);
power = gfMultiply(power, index);
}
}
return value;
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Shamir coefficients remain uncleared

splitSecret allocates the random polynomial coefficients but never overwrites them; only the generated shares are cleared by the caller. This leaves sensitive material capable of deriving every share in JavaScript heap memory until garbage collection.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/pq-threshold-seal/ts/src/index.ts
Line: 549-563

Comment:
**Shamir coefficients remain uncleared**

`splitSecret` allocates the random polynomial coefficients but never overwrites them; only the generated shares are cleared by the caller. This leaves sensitive material capable of deriving every share in JavaScript heap memory until garbage collection.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +297 to +298
let mut plaintext = decrypt(&kek, &nonce, &recipient.wrapped_share, &aad)?;
kek.zeroize();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Authentication failures retain the KEK

The ? on decrypt returns before kek.zeroize() when a tampered share or wrong context fails authentication. As a result, this expected failure path leaves the derived wrapping key in stack memory rather than clearing it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/pq-threshold-seal/rust/src/lib.rs
Line: 297-298

Comment:
**Authentication failures retain the KEK**

The `?` on `decrypt` returns before `kek.zeroize()` when a tampered share or wrong context fails authentication. As a result, this expected failure path leaves the derived wrapping key in stack memory rather than clearing it.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +28 to +35
export interface BindingContext {
/** Application or protocol domain. */
readonly domain: Uint8Array;
/** Session or transaction identifier. */
readonly session: Uint8Array;
/** Application-computed hash of the ordered recipient roster. */
readonly rosterHash: Uint8Array;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Plain data shapes use interfaces

BindingContext and the other exported DTO-like structures are plain readonly data shapes without extension or implementation requirements. Define these as type aliases to follow the repository's TypeScript convention consistently.

Rule Used: Use type instead of interface for DTOs and sim... (source)

Learned From
cytonic-network/ai-frontend#48

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/pq-threshold-seal/ts/src/index.ts
Line: 28-35

Comment:
**Plain data shapes use interfaces**

`BindingContext` and the other exported DTO-like structures are plain readonly data shapes without extension or implementation requirements. Define these as type aliases to follow the repository's TypeScript convention consistently.

**Rule Used:** Use `type` instead of `interface` for DTOs and sim... ([source](https://app.greptile.com/multivm-labs/-/custom-context?memory=2b2a7a55-162e-44b9-8c4c-3f52514f7037))

**Learned From**
[cytonic-network/ai-frontend#48](https://github.com/cytonic-network/ai-frontend/pull/48)

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

Proposal: generic PQ threshold seal envelope using ML-KEM-768 and Shamir shares

1 participant