sudp
Secret-Use Delegation Protocol — protocol-level secret use for agentic systems, in Rust.
sudp lets an autonomous requester propose a secret-backed operation, an Authorizer authorize
exactly that operation, and a custodian perform it — without the requester ever seeing
reusable authority over the secret. The unit of delegation is one use, not the secret.
┌─────────────────────────┐
│ Authorizer A │
│ (passkey on a device) │
└────────────┬────────────┘
│ signs β over (DS ‖ r ‖ H(o))
▼
┌──────────────┐ ┌───────────────┐ ┌──────────────┐
│ Requester R │ ─o─▶ │ Custodian T │ ─s─▶ │ Environment │
│ (agent) │ │ (this crate) │ │ E │
│ │◀ρ────│ holds sealed Σ│ │ │
└──────────────┘ └───────────────┘ └──────────────┘
R (the agent / LLM tool runtime) never receives the secret s. T only spends s on
operations A has authorized. Reusable authority does not cross R's boundary.
Status
Pre-1.0. MSRV 1.85 (driven by transitive base64ct 1.8+ which requires edition 2024). Wire format and trait shapes may move before the 1.0 cut.
- 21 unit + 22 end-to-end tests pass (incl. HPKE export, cross-device envelope, custom act-type extension, lifecycle rotate/enroll/revoke, strict-recipient export, cross-language conformance with
@sudp-protocol/authorizeron β,derive_wrapping_key, and AEAD-as-wrap encrypt). cargo clippy --all-targetsis clean.cargo check --no-default-featuresbuilds.
Try it
Walks through Phase I (setup) → Phase II (issue freshness r, sign β at A, redeem at
T) → Phase III (use inside T's boundary), with a mock authenticator so you don't
need a real passkey to see the shape.
Minimal usage
use *;
// Pick the standard primitive profile + WebAuthn as the authenticator.
let mut custodian: = new;
// Phase I — build Σ₀ from an initial M and one enrolled passkey.
let sealed = custodian.setup?;
// Phase II.1 — issue a fresh r token. A signs β = H(DS_bind ‖ r ‖ H(o)).
let r = custodian.issue_freshness;
// ... client computes β, gets σ from the authenticator, sends Grant ...
// Phase II.3 — redeem the grant.
let redeemed = custodian.redeem_grant?;
// Phase III.1 — use the secret inside T's boundary; R never sees it.
let response = custodian.execute_use?;
See examples/end_to_end.rs for a runnable variant and
tests/e2e.rs for adversarial cases (tampering, replay, rotation
lockout, revocation). For the three-role flow with the TypeScript Authorizer
and Requester talking over HTTP, see
../../examples/protocol-demo/.
Concepts
- Operation
o = (act, bind, valid)— the canonical A↔T contract.actcarries the semantic class (use,export,write,rotate,enroll,revoke, or profile-definedCustom), thetarget, and adapter-canonicalized scope. - Grant
G = (o, r, cid, W*, σ*, opt)— the one-shot authorization artifact.σ*bindsβ = H(DS_bind ‖ r ‖ H(o));W*arrives over the confidentialA → Tleg. - Sealed state
Σ = (C, {(cid, η, K̂)}, Reg, ver)— whatTpersists.Σalone is insufficient to recoverM; an authenticator invocation is required. - Custodian — façade over the three phases:
setup,issue_freshness,build_conveyance,redeem_grant,execute_use,execute_export,execute_lifecycle,execute_enroll,execute_revoke.
Extensions
| Extension | Module | Default? |
|---|---|---|
Batch approve — single σ over ops = (o_1, …, o_n) |
sudp::batch |
✓ |
Lifecycle: Write / Rotate / Enroll / Revoke |
Custodian::execute_* |
✓ |
Conveyance payload (o, r, {(cid_c, η_c)}) |
Custodian::build_conveyance |
✓ |
Recipient-protected export — standard Kem + Kdf + Aead composition |
sudp::phases::consumption::{seal_export, open_export} |
✓ (closure-based) |
HPKE-DHKEM backend — DhKemP256HkdfSha256 realising Kem |
sudp::primitives::HpkeDhKem |
feature hpke |
Cross-device envelope — k_xd = KDF(ss; r, DS_xd_enc ‖ pk_A ‖ pk_T) + AEAD with AD = H(pk_A ‖ pk_T ‖ r) |
sudp::xdevice |
✓ |
Custom act types — ActType::Custom(String); β/σ verification stays generic, deployment dispatches |
sudp::ActType::Custom |
✓ |
What the cross-device module gives you
The crate ships the symmetric envelope primitives — KDF stitching plus an AEAD
sealing layer with channel-binding AD over (pk_A, pk_T, r). It does not ship the
ECDH key-agreement primitive (caller picks p256::ecdh, x25519-dalek, an HSM, etc.
and passes the shared secret ss in) nor the pk_T trust establishment (signature
under a long-term key, OOB QR, PAKE — all profile choices). See
tests/e2e.rs's xdevice_envelope_round_trips_grant for the full
shape with p256::ecdh.
Customizing primitives
The crate exposes each cryptographic interface as a trait under
sudp::primitives. Concrete deployments pick the granularity that
fits.
Granularity 1 — use the standard profile
let custodian: = new;
StdPrimitives bundles:
| Role | Type | Backed by |
|---|---|---|
Hash |
Sha256 |
sha2 |
Kdf |
HkdfSha256 |
hkdf |
Aead |
ChaCha20Poly1305 |
chacha20poly1305 |
Wrap |
AeadWrap<ChaCha20Poly1305> |
AEAD-as-wrap, AD = DS_wrap ‖ cid ‖ ver |
Csprng |
OsCsprng |
rand::rngs::OsRng |
Granularity 2 — replace a single primitive
Write your own type implementing one trait (e.g. an HSM-backed AEAD), then assemble a
PrimitiveSuite:
;
;
let custodian: = new;
Granularity 3 — bring your own everything
Implement every trait (e.g. for a FIPS-validated stack, post-quantum experiment, or pure
AES-KW key wrap without AEAD), and you control the entire crypto surface. The protocol
logic in phases/ only sees S::Hash, S::Aead, etc. — no built-in primitive is
hardcoded.
Authenticator is a separate axis
Authenticator is the Authorizer-side tamper-resistant module and
its verifier. It is not inside PrimitiveSuite because it carries four associated
types (Enrollment, Assertion, PublicKey, Context) and is swapped much more often
than crypto primitives — for tests, for HSMs that aren't WebAuthn, for OS-credential
mediators.
// ▼ crypto bundle ▼ Authorizer-side authenticator
let custodian: = new;
let custodian: = new;
let custodian: = new;
WebAuthn (ES256 / P-256 with the PRF extension) is shipped as the default backend;
write your own by implementing verify_enrollment and verify_assertion.
Freshness store is the third axis
Custodian<S, A, F>'s third parameter is the r-token store. The default is an
in-memory single-process pool; swap in a Redis-backed store, a database, or anything else
that implements FreshnessStore.
Feature flags
| Feature | Default | Pulls in |
|---|---|---|
std-primitives |
✓ | sha2, hkdf, chacha20poly1305, rand |
webauthn |
✓ | p256, ES256/P-256 assertion verifier |
json-canonical |
✓ | reserved; JCS canonical encoder is always on |
hpke |
✗ | hpke, rand_core 0.9; exposes HpkeDhKem<…> for the Kem trait + DhKemP256HkdfSha256 type alias |
Disable both default features and bring your own primitives:
= { = "0.1", = false }
What's in scope
- Abstract primitive traits and a standards-based default profile.
Operation,Grant,RedeemedGrant,SealedState,ProtectedState,BatchOperations.- Custodian façade for Phases I / II / III, batch grants, and lifecycle ops with per-write rotation (peer-map recoverability).
- WebAuthn assertion / enrollment verification.
What's out of scope
- HTTP / transport (TLS 1.3, cross-device handshake — these belong in the deployment).
- Tool-call →
Operationcompilation (the adapter step is per-tool and lives outside the protocol core). - Trusted rendering at
A(the crate emits canonical bytes; UI rendering is the deployment's job). - Persistence of
SealedState(atomicity is a deployment invariant). - Rotation of the authority-bearing secret at
E(deployment policy parameter).
Threat model in one paragraph
If the requester R is fully compromised (prompt injection, tool-side content, scratchpad
rewriting, runtime shim), it cannot read the secret s, cannot derive any reusable
artifact from s, cannot replay an old grant (single-use r consumed at redemption),
and cannot substitute an operation past authorization (any tampering with o changes β
and fails signature verification). It can at most propose adversarial operations to T
and ask A to approve them. sudp does not protect against A approving a
dangerous-but-correctly-rendered operation, trusted-rendering failures inside A's
client, or runtime compromise of T itself.
License
Apache-2.0. See LICENSE.