sudp 0.1.0

Secret-Use Delegation Protocol — protocol-level secret use for agentic systems
Documentation

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/authorizer on β, derive_wrapping_key, and AEAD-as-wrap encrypt).
  • cargo clippy --all-targets is clean.
  • cargo check --no-default-features builds.

Try it

cargo run --example end_to_end

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 sudp::prelude::*;

// Pick the standard primitive profile + WebAuthn as the authenticator.
let mut custodian: Custodian<StdPrimitives, WebAuthn> = Custodian::new("custodian-id");

// Phase I — build Σ₀ from an initial M and one enrolled passkey.
let sealed = custodian.setup(
    protected_state,           // ProtectedState (M₀)
    enrollment,                // WebAuthnEnrollment
    prf_salt,                  // η_c, 32 bytes
    wrapping_key,              // W_c, derived at A from the PRF extension
    &auth_context,             // AuthenticatorContext { rp_id, origin, require_uv }
)?;

// 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(grant, &auth_context, &sealed, now_unix)?;

// Phase III.1 — use the secret inside T's boundary; R never sees it.
let response = custodian.execute_use(&redeemed, &sealed, |target, s_o| {
    /* call the environment with s_o; return only what o authorizes */
    Ok(call_external(target, s_o))
})?;

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. act carries the semantic class (use, export, write, rotate, enroll, revoke, or profile-defined Custom), the target, 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 confidential A → T leg.
  • Sealed state Σ = (C, {(cid, η, K̂)}, Reg, ver) — what T persists. Σ alone is insufficient to recover M; 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 backendDhKemP256HkdfSha256 realising Kem sudp::primitives::HpkeDhKem feature hpke
Cross-device envelopek_xd = KDF(ss; r, DS_xd_enc ‖ pk_A ‖ pk_T) + AEAD with AD = H(pk_A ‖ pk_T ‖ r) sudp::xdevice
Custom act typesActType::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: Custodian<StdPrimitives, WebAuthn> = 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:

struct HsmAead;
impl Aead for HsmAead { /* delegate to your HSM */ }

struct MySuite;
impl PrimitiveSuite for MySuite {
    type Hash   = Sha256;                  // standard
    type Kdf    = HkdfSha256;              // standard
    type Aead   = HsmAead;                 // custom
    type Wrap   = AeadWrap<HsmAead>;       // reuse the wrap shape
    type Csprng = OsCsprng;                // standard
}

let custodian: Custodian<MySuite, WebAuthn> = 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: Custodian<StdPrimitives, WebAuthn>     = Custodian::new("...");
let custodian: Custodian<StdPrimitives, MockForTests> = Custodian::new("...");
let custodian: Custodian<StdPrimitives, HsmBackend>   = 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:

sudp = { version = "0.1", default-features = 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 → Operation compilation (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.