Skip to main content

whipplescript_custody/
lib.rs

1//! Credential custody protocol (DR-0053).
2//!
3//! whip holds **handles**; a custodian in a separate security principal holds
4//! material and performs operations. This crate is the seam between them: the
5//! handle/sentinel types, the operation vocabulary, and the transport trait.
6//! It contains no backend and no cryptography — those live in
7//! `whipplescript-custodian`.
8//!
9//! The vocabulary is deliberately closed and there is **no `get(handle)` at
10//! any layer** (DR-0053 §2): the only power the custodian offers is
11//! *substitute / sign / verify / derive / wrap / unwrap / mint under policy*.
12//! One caller with a legitimate reason to fetch plaintext would re-establish
13//! extractability for all, so the operation does not exist to be called.
14//! `models/maude/credential-no-eliminator.maude` proves the language side of
15//! this; the `-REVEAL` sibling carries the rejected `get` design.
16//!
17//! The custodian stays semantically dumb (§3): whip constructs entire
18//! requests with a typed [`Sentinel`] exactly where material belongs, and the
19//! custodian's whole power is substitution at the marked slot if policy
20//! permits. It does not parse payloads, choose endpoints, or know what an API
21//! is.
22
23pub mod canon;
24#[cfg(target_family = "unix")]
25pub mod client;
26
27use std::fmt;
28
29use serde::{Deserialize, Serialize};
30
31/// Wire protocol identifier, carried on every call so a custodian can refuse
32/// a caller from a different protocol generation.
33pub const CUSTODY_PROTOCOL: &str = "whipplescript.custody.v1";
34
35/// The conventional environment variable naming the custodian socket.
36///
37/// Vocabulary, not transport: the *name* a caller reads to learn whether an
38/// operator asked for a custodian is meaningful on every target, while the
39/// Unix-socket [`client`] that connects to it is not. It lives here so a
40/// non-Unix build can still tell a configured custodian from an absent one and
41/// refuse accordingly, rather than losing the distinction along with the
42/// transport.
43pub const CUSTODIAN_SOCKET_ENV: &str = "WHIPPLESCRIPT_CUSTODIAN_SOCKET";
44
45// ---------------------------------------------------------------------------
46// Names and kinds
47// ---------------------------------------------------------------------------
48
49/// A credential's stable name. Resource identity is `credential:<name>` —
50/// deliberately not backend-qualified (`vault:`, `kms:`), so identity survives
51/// backend migration and grants keep working (DR-0053 §5).
52#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
53#[serde(transparent)]
54pub struct CredentialName(String);
55
56impl CredentialName {
57    /// Validated constructor. Names are one or more `/`-separated segments of
58    /// `[a-z0-9_-]`, e.g. `stripe_api` or `acme/stripe-live`.
59    pub fn new(name: &str) -> Result<Self, String> {
60        if name.is_empty() {
61            return Err("credential name is empty".to_string());
62        }
63        let ok_segment = |s: &str| {
64            !s.is_empty()
65                && s.chars()
66                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
67        };
68        if !name.split('/').all(ok_segment) {
69            return Err(format!(
70                "invalid credential name {name:?}: segments must be non-empty [a-z0-9_-]"
71            ));
72        }
73        Ok(Self(name.to_string()))
74    }
75
76    pub fn as_str(&self) -> &str {
77        &self.0
78    }
79
80    /// The governance resource identifier: `credential:<name>`.
81    pub fn resource_id(&self) -> String {
82        format!("credential:{}", self.0)
83    }
84
85    /// Parse a `credential:<name>` resource identifier.
86    pub fn from_resource_id(id: &str) -> Result<Self, String> {
87        match id.strip_prefix("credential:") {
88            Some(rest) => Self::new(rest),
89            None => Err(format!("not a credential resource id: {id:?}")),
90        }
91    }
92}
93
94impl fmt::Display for CredentialName {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(&self.0)
97    }
98}
99
100/// One parsed operator-config credential reference (DR-0053 *Migration*).
101///
102/// Five subsystems arrived at reference-not-value independently with four
103/// spellings (`env:OPENAI_API_KEY`, `secret:claude`, `credential:model`,
104/// `credential:account:openai`); this is the one namespace they unify onto.
105/// `credential:<name>` names a custodian entry; every legacy spelling still
106/// parses but resolves **degraded at r0** — visible, never silent — and is
107/// removed at the first release that requires a rung.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum CredentialRef {
110    /// `credential:<name>` — a custodian entry; the rung is whatever the
111    /// custodian derives from evidence.
112    Custodian(CredentialName),
113    /// `env:<VAR>` — the legacy environment shim: material read from whip's
114    /// own environment, so the honest resolution is r0 and degraded.
115    LegacyEnv { var: String },
116    /// A pre-unification spelling (`secret:<x>`, `credential:account:<x>`,
117    /// or a legacy `credential:<x>` that names no custodian entry). Carried
118    /// as an opaque tag so existing bindings keep matching; r0 degraded.
119    LegacyTag { tag: String },
120}
121
122impl CredentialRef {
123    pub fn parse(raw: &str) -> Result<Self, String> {
124        if raw.trim().is_empty() {
125            return Err("empty credential reference".to_string());
126        }
127        if let Some(var) = raw.strip_prefix("env:") {
128            if var.is_empty() {
129                return Err("env: credential reference names no variable".to_string());
130            }
131            return Ok(CredentialRef::LegacyEnv {
132                var: var.to_string(),
133            });
134        }
135        if let Some(rest) = raw.strip_prefix("credential:") {
136            // The canonical namespace — but pre-unification ids like
137            // `credential:account:openai` are not valid custodian names and
138            // stay legacy tags rather than parse errors (no config break).
139            if let Ok(name) = CredentialName::new(rest) {
140                return Ok(CredentialRef::Custodian(name));
141            }
142            return Ok(CredentialRef::LegacyTag {
143                tag: raw.to_string(),
144            });
145        }
146        if raw.starts_with("secret:") {
147            return Ok(CredentialRef::LegacyTag {
148                tag: raw.to_string(),
149            });
150        }
151        Err(format!(
152            "unrecognized credential reference {raw:?}: use `credential:<name>` (custodian \
153             entry) or the legacy `env:<VAR>` shim"
154        ))
155    }
156
157    /// The honest rung/degraded pair this reference can claim WITHOUT asking
158    /// a custodian: legacy shims are r0 degraded by construction. For
159    /// `Custodian` references the truth is whatever the custodian's reply
160    /// derives — this method reports the r0 floor and the caller must prefer
161    /// the reply's values (`credential-rung-evidence.maude`: configuration
162    /// is not evidence).
163    pub fn shim_rung(&self) -> (Rung, bool) {
164        (Rung::Process, true)
165    }
166
167    /// Whether this reference is a legacy spelling that the unification will
168    /// retire once a rung is required.
169    pub fn is_legacy(&self) -> bool {
170        !matches!(self, CredentialRef::Custodian(_))
171    }
172}
173
174/// The declared kind of a credential. `kind` exists so the checker can
175/// statically reject `sign … with stripe_api` (DR-0053 §5); the custodian's
176/// registered kind is authoritative and mismatch is a check error.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
178#[serde(rename_all = "kebab-case")]
179pub enum CredentialKind {
180    /// An opaque bearer token (Authorization: Bearer …).
181    Bearer,
182    /// HTTP basic credentials.
183    Basic,
184    /// Raw material substituted verbatim at the marked slot.
185    Raw,
186    /// A symmetric HMAC-SHA-256 key (Stripe/GitHub/Slack webhooks).
187    HmacSha256,
188    /// An Ed25519 signing key.
189    Ed25519,
190    /// An AWS SigV4 secret key (signs via the §7 derivation chain).
191    AwsSigv4,
192    /// An RS256 private key (GitHub App JWTs, service accounts).
193    JwtRs256,
194}
195
196impl CredentialKind {
197    pub fn as_str(&self) -> &'static str {
198        match self {
199            CredentialKind::Bearer => "bearer",
200            CredentialKind::Basic => "basic",
201            CredentialKind::Raw => "raw",
202            CredentialKind::HmacSha256 => "hmac-sha256",
203            CredentialKind::Ed25519 => "ed25519",
204            CredentialKind::AwsSigv4 => "aws-sigv4",
205            CredentialKind::JwtRs256 => "jwt-rs256",
206        }
207    }
208
209    pub fn parse(s: &str) -> Result<Self, String> {
210        match s {
211            "bearer" => Ok(CredentialKind::Bearer),
212            "basic" => Ok(CredentialKind::Basic),
213            "raw" => Ok(CredentialKind::Raw),
214            "hmac-sha256" => Ok(CredentialKind::HmacSha256),
215            "ed25519" => Ok(CredentialKind::Ed25519),
216            "aws-sigv4" => Ok(CredentialKind::AwsSigv4),
217            "jwt-rs256" => Ok(CredentialKind::JwtRs256),
218            other => Err(format!("unknown credential kind {other:?}")),
219        }
220    }
221
222    /// Which operations this kind supports. A `sign` against a `bearer`
223    /// credential is a kind mismatch, statically and at the custodian.
224    pub fn supports(&self, op: Operation) -> bool {
225        match op {
226            Operation::Request => matches!(
227                self,
228                CredentialKind::Bearer
229                    | CredentialKind::Basic
230                    | CredentialKind::Raw
231                    | CredentialKind::AwsSigv4
232            ),
233            Operation::Sign | Operation::Verify => matches!(
234                self,
235                CredentialKind::HmacSha256
236                    | CredentialKind::Ed25519
237                    | CredentialKind::AwsSigv4
238                    | CredentialKind::JwtRs256
239            ),
240            Operation::Derive => matches!(
241                self,
242                CredentialKind::HmacSha256 | CredentialKind::AwsSigv4 | CredentialKind::Raw
243            ),
244            Operation::Wrap | Operation::Unwrap => {
245                matches!(self, CredentialKind::Raw | CredentialKind::HmacSha256)
246            }
247            Operation::Mint => matches!(
248                self,
249                CredentialKind::Bearer | CredentialKind::Basic | CredentialKind::Raw
250            ),
251        }
252    }
253}
254
255impl fmt::Display for CredentialKind {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        f.write_str(self.as_str())
258    }
259}
260
261// ---------------------------------------------------------------------------
262// The rung ladder
263// ---------------------------------------------------------------------------
264
265/// Sealing rung, ordered (DR-0053 §4). Derived from evidence by the
266/// custodian, never asserted in configuration — `require credential <rung>`
267/// in the signed policy compares against what the custodian *derived*
268/// (`models/maude/credential-rung-evidence.maude`: configuration is not
269/// evidence).
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
271#[serde(rename_all = "kebab-case")]
272pub enum Rung {
273    /// r0: in-process, sealed at rest under a passphrase-derived key. whip's
274    /// *language* cannot read it; an escape can. Dev only, tagged degraded.
275    Process,
276    /// r1: OS keyring (libsecret / Keychain / DPAPI). Survives file read;
277    /// session-bound.
278    OsKeyring,
279    /// r2: TPM 2.0 PCR-sealed / Secure Enclave / PKCS#11 — non-extractability
280    /// is literally true.
281    Hardware,
282    /// r3: OpenBao / Vault / KMS / Home broker — material never exists on the
283    /// box.
284    Remote,
285}
286
287impl Rung {
288    pub fn as_str(&self) -> &'static str {
289        match self {
290            Rung::Process => "process",
291            Rung::OsKeyring => "os-keyring",
292            Rung::Hardware => "hardware",
293            Rung::Remote => "remote",
294        }
295    }
296
297    pub fn parse(s: &str) -> Result<Self, String> {
298        match s {
299            "process" | "r0" => Ok(Rung::Process),
300            "os-keyring" | "r1" => Ok(Rung::OsKeyring),
301            "hardware" | "r2" => Ok(Rung::Hardware),
302            "remote" | "r3" => Ok(Rung::Remote),
303            other => Err(format!("unknown sealing rung {other:?}")),
304        }
305    }
306
307    /// Short ladder label (`r0`…`r3`), for run records and diagnostics.
308    pub fn ladder_label(&self) -> &'static str {
309        match self {
310            Rung::Process => "r0",
311            Rung::OsKeyring => "r1",
312            Rung::Hardware => "r2",
313            Rung::Remote => "r3",
314        }
315    }
316}
317
318impl fmt::Display for Rung {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        f.write_str(self.as_str())
321    }
322}
323
324// ---------------------------------------------------------------------------
325// Operations and the two grant classes
326// ---------------------------------------------------------------------------
327
328/// The closed operation vocabulary (DR-0053 §2). There is no `Get` variant
329/// and never will be; a wire message naming an unknown operation fails to
330/// deserialize.
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
332#[serde(rename_all = "lowercase")]
333pub enum Operation {
334    Request,
335    Sign,
336    Verify,
337    Derive,
338    Wrap,
339    Unwrap,
340    Mint,
341}
342
343impl Operation {
344    pub const ALL: [Operation; 7] = [
345        Operation::Request,
346        Operation::Sign,
347        Operation::Verify,
348        Operation::Derive,
349        Operation::Wrap,
350        Operation::Unwrap,
351        Operation::Mint,
352    ];
353
354    pub fn as_str(&self) -> &'static str {
355        match self {
356            Operation::Request => "request",
357            Operation::Sign => "sign",
358            Operation::Verify => "verify",
359            Operation::Derive => "derive",
360            Operation::Wrap => "wrap",
361            Operation::Unwrap => "unwrap",
362            Operation::Mint => "mint",
363        }
364    }
365
366    pub fn parse(s: &str) -> Result<Self, String> {
367        match s {
368            "request" => Ok(Operation::Request),
369            "sign" => Ok(Operation::Sign),
370            "verify" => Ok(Operation::Verify),
371            "derive" => Ok(Operation::Derive),
372            "wrap" => Ok(Operation::Wrap),
373            "unwrap" => Ok(Operation::Unwrap),
374            "mint" => Ok(Operation::Mint),
375            other => Err(format!("unknown custody operation {other:?}")),
376        }
377    }
378
379    /// The two grant classes of DR-0053 §14. Narrowable operations (`request`
380    /// host/method/path globs, `mint` vendor scope strings) *require* their
381    /// list in a grant; the rest are named bare and a glob list on one of
382    /// them is a check error, not silently ignored.
383    pub fn narrowable(&self) -> bool {
384        matches!(self, Operation::Request | Operation::Mint)
385    }
386}
387
388impl fmt::Display for Operation {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        f.write_str(self.as_str())
391    }
392}
393
394// ---------------------------------------------------------------------------
395// Sentinels — the marked slot
396// ---------------------------------------------------------------------------
397
398/// Presentation form for material at a marked slot (DR-0053 §5): usable in
399/// any string position, lowering the handle to a sentinel.
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
401#[serde(rename_all = "lowercase")]
402pub enum PresentationForm {
403    /// `Bearer <material>`
404    Bearer,
405    /// `Basic <base64(user:material)>` — the custodian holds `user:pass`
406    /// material for `basic`-kind credentials and encodes at substitution.
407    Basic,
408    /// Material verbatim.
409    Raw,
410}
411
412impl PresentationForm {
413    pub fn as_str(&self) -> &'static str {
414        match self {
415            PresentationForm::Bearer => "bearer",
416            PresentationForm::Basic => "basic",
417            PresentationForm::Raw => "raw",
418        }
419    }
420
421    pub fn parse(s: &str) -> Result<Self, String> {
422        match s {
423            "bearer" => Ok(PresentationForm::Bearer),
424            "basic" => Ok(PresentationForm::Basic),
425            "raw" => Ok(PresentationForm::Raw),
426            other => Err(format!("unknown presentation form {other:?}")),
427        }
428    }
429}
430
431impl fmt::Display for PresentationForm {
432    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
433        f.write_str(self.as_str())
434    }
435}
436
437const SENTINEL_OPEN: &str = "{{whipplescript-credential:";
438const SENTINEL_CLOSE: &str = "}}";
439
440/// A typed placeholder marking exactly where material belongs in a request
441/// whip constructs. The custodian substitutes at marked slots and nowhere
442/// else. The textual form is `{{whipplescript-credential:<name>:<form>}}`.
443///
444/// This generalizes DR-0042's fixed `whipplescript-model-broker` marker: the
445/// sentinel names its handle and presentation form, so one request may carry
446/// slots for several credentials.
447#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
448pub struct Sentinel {
449    pub credential: CredentialName,
450    pub form: PresentationForm,
451}
452
453impl Sentinel {
454    pub fn new(credential: CredentialName, form: PresentationForm) -> Self {
455        Self { credential, form }
456    }
457
458    /// The textual slot marker embedded in headers/bodies whip constructs.
459    pub fn render(&self) -> String {
460        format!(
461            "{SENTINEL_OPEN}{}:{}{SENTINEL_CLOSE}",
462            self.credential, self.form
463        )
464    }
465
466    /// Parse one rendered sentinel.
467    pub fn parse(text: &str) -> Result<Self, String> {
468        let inner = text
469            .strip_prefix(SENTINEL_OPEN)
470            .and_then(|t| t.strip_suffix(SENTINEL_CLOSE))
471            .ok_or_else(|| format!("not a credential sentinel: {text:?}"))?;
472        let (name, form) = inner
473            .rsplit_once(':')
474            .ok_or_else(|| format!("malformed credential sentinel: {text:?}"))?;
475        Ok(Self {
476            credential: CredentialName::new(name)?,
477            form: PresentationForm::parse(form)?,
478        })
479    }
480
481    /// Every sentinel occurring in `text`, in order, with its byte range.
482    /// Malformed markers (an opener with no valid close/name/form) are
483    /// reported as errors rather than skipped: a slot the author believes is
484    /// marked but the custodian would not substitute is a silent
485    /// authentication failure at best.
486    pub fn find_all(text: &str) -> Result<Vec<(std::ops::Range<usize>, Sentinel)>, String> {
487        let mut out = Vec::new();
488        let mut at = 0usize;
489        while let Some(rel) = text[at..].find(SENTINEL_OPEN) {
490            let start = at + rel;
491            let close_rel = text[start..]
492                .find(SENTINEL_CLOSE)
493                .ok_or_else(|| "unterminated credential sentinel".to_string())?;
494            let end = start + close_rel + SENTINEL_CLOSE.len();
495            let sentinel = Sentinel::parse(&text[start..end])?;
496            out.push((start..end, sentinel));
497            at = end;
498        }
499        Ok(out)
500    }
501}
502
503impl fmt::Display for Sentinel {
504    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
505        f.write_str(&self.render())
506    }
507}
508
509// ---------------------------------------------------------------------------
510// Requests, envelopes, and operation payloads
511// ---------------------------------------------------------------------------
512
513/// An outbound HTTP request whip constructed in full, with sentinels at the
514/// marked slots. The custodian substitutes and egresses; it never chooses any
515/// part of this.
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517pub struct EgressRequest {
518    pub method: String,
519    pub url: String,
520    pub headers: Vec<(String, String)>,
521    /// Base64 of the body bytes, absent for bodiless requests. Base64 rather
522    /// than a string so binary bodies survive the wire; sentinels inside a
523    /// body are found after decoding.
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub body_b64: Option<String>,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529pub struct EgressResponse {
530    pub status: u16,
531    pub headers: Vec<(String, String)>,
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub body_b64: Option<String>,
534}
535
536/// Signature algorithm for `sign`/`verify`. Distinct from [`CredentialKind`]:
537/// the kind is what the credential *is*, the alg is what this call asks it to
538/// do, and the custodian refuses mismatches.
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
540#[serde(rename_all = "kebab-case")]
541pub enum SignatureAlg {
542    HmacSha256,
543    Ed25519,
544    /// RSASSA-PKCS1-v1_5 with SHA-256 (JWT RS256).
545    RsaSha256,
546}
547
548/// A label-carrying envelope (DR-0053 §13). The envelope records the caller's
549/// IFC label and `unwrap` restores it, so the wrap → store → unwrap roundtrip
550/// cannot launder; AEAD associated data binds the ciphertext to
551/// (credential, context, label) so envelopes are not swappable between
552/// contexts even with every label intact
553/// (`models/maude/credential-wrap-carriage.maude`, `-UNBOUND` sibling).
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
555pub struct Envelope {
556    /// The wrapping credential. A different credential does not open the
557    /// envelope.
558    pub credential: CredentialName,
559    /// The context the envelope was produced for; unwrap under a different
560    /// context is refused by AEAD, not by comparison.
561    pub context: String,
562    /// The carried label, recorded at wrap and restored at unwrap. Opaque to
563    /// the custodian: whatever the caller's IFC layer serializes.
564    pub label: serde_json::Value,
565    pub nonce_b64: String,
566    pub ciphertext_b64: String,
567}
568
569/// How the custodian extracts the minted material from an exchange response
570/// (DR-0053 *Open*, OAuth response capture): whip declares the path, the
571/// custodian applies it — a dumb instruction, not protocol semantics. Only
572/// simple dotted paths (`access_token`, `data.token`) are supported;
573/// extraction is not a query language.
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575pub struct MintExtraction {
576    /// Dotted JSON path to the secret member of the exchange response body.
577    pub token_path: String,
578    /// Dotted paths to non-secret members returned to whip alongside the
579    /// handle (expiry, scope echo, token type).
580    #[serde(default)]
581    pub public_paths: Vec<String>,
582}
583
584/// One custody operation. Externally tagged by `op`, and the vocabulary is
585/// closed: a message with `"op": "get"` — or any name outside this list —
586/// fails to deserialize.
587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
588#[serde(tag = "op", rename_all = "lowercase")]
589pub enum CustodyOp {
590    /// Substitute at marked slots, egress, return the response.
591    Request {
592        credential: CredentialName,
593        request: EgressRequest,
594        /// How many sentinel slots the CONSTRUCTING program placed, declared
595        /// out of band from the request text itself. The custodian finds
596        /// sentinels by scanning finished text, which cannot tell a slot the
597        /// author wrote from one that arrived inside interpolated data; if a
598        /// value could carry a sentinel into a header, URL or body, the
599        /// custodian would fill it with real material at a position the author
600        /// never designated — and the `raw` form would put the bare secret
601        /// there. Declaring the count out of band makes any such injection a
602        /// refusal rather than a substitution: data can add an occurrence but
603        /// cannot remove the author's, so the totals disagree.
604        slots: usize,
605    },
606    /// Keyed signature, optionally through a derivation chain (§7): the
607    /// custodian folds `HMAC` over the chain from the sealed material, then
608    /// signs the payload with the final key. For AWS SigV4 the chain is
609    /// `[date, region, service, "aws4_request"]` and whip never holds
610    /// `kSigning`, which is itself a credential.
611    Sign {
612        credential: CredentialName,
613        alg: SignatureAlg,
614        #[serde(default)]
615        derivation: Vec<String>,
616        payload_b64: String,
617    },
618    /// Constant-time verification (§6) — in the custodian, since a timing
619    /// oracle in whip leaks the key.
620    Verify {
621        credential: CredentialName,
622        alg: SignatureAlg,
623        payload_b64: String,
624        signature_b64: String,
625    },
626    /// HKDF subkey; returns a handle, never material.
627    Derive {
628        credential: CredentialName,
629        context: String,
630    },
631    /// Envelope-encrypt application data whip may persist but not read back
632    /// without the custodian (§13).
633    Wrap {
634        credential: CredentialName,
635        plaintext_b64: String,
636        label: serde_json::Value,
637        context: String,
638    },
639    /// Open an envelope. Legitimately returns plaintext to whip — wrapped
640    /// data is application data, not credential material — so unwrap is
641    /// scoped, budgeted, and audited like any other use (§13).
642    Unwrap {
643        credential: CredentialName,
644        envelope: Envelope,
645        context: String,
646    },
647    /// Credential exchange, custodian-executed so whip never sees the token
648    /// in the response body. Returns a handle plus the non-secret half.
649    Mint {
650        credential: CredentialName,
651        scope: Vec<String>,
652        ttl_secs: u64,
653        exchange: EgressRequest,
654        extraction: MintExtraction,
655        /// Slots the constructing program placed in `exchange`, declared out of
656        /// band for the same reason as [`CustodyOp::Request::slots`]: the
657        /// exchange is whip-constructed text carrying the PARENT credential's
658        /// sentinels, so an injected slot here would present the parent to a
659        /// position the author never designated.
660        exchange_slots: usize,
661    },
662}
663
664impl CustodyOp {
665    pub fn operation(&self) -> Operation {
666        match self {
667            CustodyOp::Request { .. } => Operation::Request,
668            CustodyOp::Sign { .. } => Operation::Sign,
669            CustodyOp::Verify { .. } => Operation::Verify,
670            CustodyOp::Derive { .. } => Operation::Derive,
671            CustodyOp::Wrap { .. } => Operation::Wrap,
672            CustodyOp::Unwrap { .. } => Operation::Unwrap,
673            CustodyOp::Mint { .. } => Operation::Mint,
674        }
675    }
676
677    pub fn credential(&self) -> &CredentialName {
678        match self {
679            CustodyOp::Request { credential, .. }
680            | CustodyOp::Sign { credential, .. }
681            | CustodyOp::Verify { credential, .. }
682            | CustodyOp::Derive { credential, .. }
683            | CustodyOp::Wrap { credential, .. }
684            | CustodyOp::Unwrap { credential, .. }
685            | CustodyOp::Mint { credential, .. } => credential,
686        }
687    }
688}
689
690// ---------------------------------------------------------------------------
691// Calls, replies, attribution
692// ---------------------------------------------------------------------------
693
694/// Who is using the credential, recorded with every use — §1 claims every
695/// use is attributable, and `UsesAreRecorded` in `CredentialCustody.tla`
696/// exists so the rung floor cannot be satisfied by an unrecorded use.
697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
698pub struct UseAttribution {
699    /// The run performing the use.
700    pub run_id: String,
701    /// The acting agent/instance within the run, when one exists.
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub actor: Option<String>,
704    /// The effect key of the effect this use serves, when one exists.
705    #[serde(default, skip_serializing_if = "Option::is_none")]
706    pub effect_key: Option<String>,
707}
708
709/// One call over the transport.
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
711pub struct CustodyCall {
712    /// Must equal [`CUSTODY_PROTOCOL`]; the custodian refuses others.
713    pub protocol: String,
714    pub attribution: UseAttribution,
715    #[serde(flatten)]
716    pub op: CustodyOp,
717}
718
719impl CustodyCall {
720    pub fn new(attribution: UseAttribution, op: CustodyOp) -> Self {
721        Self {
722            protocol: CUSTODY_PROTOCOL.to_string(),
723            attribution,
724            op,
725        }
726    }
727}
728
729/// Success payloads, one per operation. None yields sealed material: `Derived`
730/// and `Minted` return handles; `Unwrapped` returns application data that was
731/// whip's to begin with.
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
733#[serde(tag = "result", rename_all = "lowercase")]
734pub enum CustodyOk {
735    Requested {
736        response: EgressResponse,
737    },
738    Signed {
739        signature_b64: String,
740    },
741    Verified {
742        /// Constant-time comparison result. `false` is a *successful* call
743        /// whose answer is "signature invalid" — the caller turns it into a
744        /// typed effect failure.
745        valid: bool,
746    },
747    Derived {
748        credential: CredentialName,
749    },
750    Wrapped {
751        envelope: Envelope,
752    },
753    Unwrapped {
754        plaintext_b64: String,
755        /// The label recorded at wrap, restored (§13): the boundary does not
756        /// launder.
757        label: serde_json::Value,
758    },
759    Minted {
760        credential: CredentialName,
761        /// Fingerprint of the minted material (non-secret).
762        fingerprint: String,
763        /// The non-secret half extracted via `public_paths`.
764        public: serde_json::Value,
765    },
766}
767
768/// Typed refusals. `Refused` reasons are closed so callers can route them;
769/// backend faults carry a message.
770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
771#[serde(tag = "error", rename_all = "kebab-case")]
772pub enum CustodyError {
773    UnknownCredential {
774        credential: CredentialName,
775    },
776    /// The declared kind does not support this operation, or the alg does not
777    /// match the material.
778    KindMismatch {
779        credential: CredentialName,
780        kind: CredentialKind,
781        operation: Operation,
782    },
783    /// No grant covers this operation for this caller.
784    OperationNotGranted {
785        credential: CredentialName,
786        operation: Operation,
787    },
788    /// A narrowable operation's grant list does not cover the target.
789    ScopeRefused {
790        credential: CredentialName,
791        detail: String,
792    },
793    /// The signed policy requires a rung the credential's evidence does not
794    /// reach.
795    RungBelowFloor {
796        required: Rung,
797        actual: Rung,
798    },
799    Revoked {
800        credential: CredentialName,
801    },
802    /// Per-credential use budget exhausted (DR-0053 §9).
803    BudgetExhausted {
804        credential: CredentialName,
805    },
806    /// AEAD refused the envelope: wrong context, wrong credential, or
807    /// tampered ciphertext. Deliberately one variant — AEAD cannot say which.
808    EnvelopeRefused,
809    /// The egress or exchange failed at the network layer.
810    EgressFailed {
811        detail: String,
812    },
813    /// Custodian-side fault.
814    Backend {
815        detail: String,
816    },
817}
818
819impl fmt::Display for CustodyError {
820    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
821        match self {
822            CustodyError::UnknownCredential { credential } => {
823                write!(f, "unknown credential {credential}")
824            }
825            CustodyError::KindMismatch {
826                credential,
827                kind,
828                operation,
829            } => write!(
830                f,
831                "credential {credential} has kind {kind}, which does not support {operation}"
832            ),
833            CustodyError::OperationNotGranted {
834                credential,
835                operation,
836            } => write!(f, "{operation} on {credential} is not granted"),
837            CustodyError::ScopeRefused { credential, detail } => {
838                write!(f, "scope refused for {credential}: {detail}")
839            }
840            CustodyError::RungBelowFloor { required, actual } => write!(
841                f,
842                "sealing rung {} is below the required floor {}",
843                actual.ladder_label(),
844                required.ladder_label()
845            ),
846            CustodyError::Revoked { credential } => write!(f, "credential {credential} is revoked"),
847            CustodyError::BudgetExhausted { credential } => {
848                write!(f, "use budget exhausted for {credential}")
849            }
850            CustodyError::EnvelopeRefused => f.write_str("envelope refused"),
851            CustodyError::EgressFailed { detail } => write!(f, "egress failed: {detail}"),
852            CustodyError::Backend { detail } => write!(f, "custodian backend fault: {detail}"),
853        }
854    }
855}
856
857/// The custodian's reply. Every reply — refusals included — carries the use
858/// id it was recorded under and the rung the credential's evidence derives,
859/// with `degraded` set when the resolution is a compatibility shim (legacy
860/// `env:` refs resolve at r0 **degraded**; DR-0053 *Migration*).
861#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
862pub struct CustodyReply {
863    pub use_id: String,
864    pub rung: Rung,
865    pub degraded: bool,
866    pub outcome: Result<CustodyOk, CustodyError>,
867}
868
869/// Transport faults, distinct from custody refusals: a refusal is the
870/// custodian speaking; a transport error means it never did.
871#[derive(Debug, Clone, PartialEq, Eq)]
872pub enum TransportError {
873    Unavailable(String),
874    Protocol(String),
875}
876
877impl fmt::Display for TransportError {
878    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
879        match self {
880            TransportError::Unavailable(d) => write!(f, "custodian unavailable: {d}"),
881            TransportError::Protocol(d) => write!(f, "custody protocol error: {d}"),
882        }
883    }
884}
885
886impl std::error::Error for TransportError {}
887
888/// The transport seam (DR-0053 §2, tracker slice 1). r0 may run in-process,
889/// but it still speaks [`CustodyCall`]/[`CustodyReply`] through this trait —
890/// wiring r0 as direct function calls would leave the principal-separation
891/// seam unexercised and make r1+ a rewrite rather than a backend swap.
892pub trait CustodyTransport: Send + Sync {
893    fn call(&self, call: CustodyCall) -> Result<CustodyReply, TransportError>;
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    fn name(s: &str) -> CredentialName {
901        CredentialName::new(s).expect("valid name")
902    }
903
904    #[test]
905    fn rungs_are_ordered() {
906        assert!(Rung::Process < Rung::OsKeyring);
907        assert!(Rung::OsKeyring < Rung::Hardware);
908        assert!(Rung::Hardware < Rung::Remote);
909        assert_eq!(Rung::parse("r2").expect("parse"), Rung::Hardware);
910        assert_eq!(Rung::parse("hardware").expect("parse"), Rung::Hardware);
911    }
912
913    #[test]
914    fn resource_identity_is_backend_free() {
915        let n = name("acme/stripe-live");
916        assert_eq!(n.resource_id(), "credential:acme/stripe-live");
917        assert_eq!(
918            CredentialName::from_resource_id("credential:acme/stripe-live").expect("roundtrip"),
919            n
920        );
921        assert!(CredentialName::from_resource_id("vault:acme/stripe-live").is_err());
922        assert!(CredentialName::new("Bad Name").is_err());
923        assert!(CredentialName::new("trailing/").is_err());
924    }
925
926    #[test]
927    fn sentinel_roundtrip_and_scan() {
928        let s = Sentinel::new(name("stripe_api"), PresentationForm::Bearer);
929        assert_eq!(s.render(), "{{whipplescript-credential:stripe_api:bearer}}");
930        assert_eq!(Sentinel::parse(&s.render()).expect("parse"), s);
931
932        let header = format!("Bearer {}", s.render());
933        let found = Sentinel::find_all(&header).expect("scan");
934        assert_eq!(found.len(), 1);
935        assert_eq!(found[0].1, s);
936        assert_eq!(&header[found[0].0.clone()], s.render());
937
938        let two = format!(
939            "{} and {}",
940            Sentinel::new(name("a"), PresentationForm::Raw).render(),
941            Sentinel::new(name("b"), PresentationForm::Basic).render()
942        );
943        assert_eq!(Sentinel::find_all(&two).expect("scan").len(), 2);
944
945        assert!(Sentinel::find_all("{{whipplescript-credential:oops").is_err());
946        assert!(Sentinel::find_all("{{whipplescript-credential:UPPER:bearer}}").is_err());
947        assert!(Sentinel::find_all("no sentinels here")
948            .expect("scan")
949            .is_empty());
950    }
951
952    #[test]
953    fn operation_grant_classes_match_dr0053_s14() {
954        let narrowable: Vec<Operation> = Operation::ALL
955            .iter()
956            .copied()
957            .filter(Operation::narrowable)
958            .collect();
959        assert_eq!(narrowable, vec![Operation::Request, Operation::Mint]);
960    }
961
962    #[test]
963    fn credential_refs_unify_with_legacy_spellings_tagged_degraded() {
964        // The canonical namespace.
965        assert_eq!(
966            CredentialRef::parse("credential:acme/stripe-live").expect("parse"),
967            CredentialRef::Custodian(name("acme/stripe-live"))
968        );
969        // Every legacy spelling still parses — and every one is degraded.
970        for legacy in [
971            "env:OPENAI_API_KEY",
972            "secret:claude",
973            "credential:account:openai",
974        ] {
975            let parsed = CredentialRef::parse(legacy).expect("legacy parses");
976            assert!(parsed.is_legacy(), "{legacy} must be legacy");
977            assert_eq!(parsed.shim_rung(), (Rung::Process, true));
978        }
979        assert!(!CredentialRef::parse("credential:model")
980            .expect("parse")
981            .is_legacy());
982        // Unknown schemes are errors, not silent passthrough — the resolver
983        // that "silently passes plaintext through otherwise" is the exact
984        // complaint DR-0053 records.
985        assert!(CredentialRef::parse("sk_live_plaintext").is_err());
986        assert!(CredentialRef::parse("env:").is_err());
987    }
988
989    #[test]
990    fn there_is_no_get_on_the_wire() {
991        // The vocabulary is closed by construction; this pins the negative at
992        // the wire layer: a message asking for `get` does not deserialize.
993        let get = serde_json::json!({
994            "protocol": CUSTODY_PROTOCOL,
995            "attribution": { "run_id": "r1" },
996            "op": "get",
997            "credential": "stripe_api",
998        });
999        assert!(serde_json::from_value::<CustodyCall>(get).is_err());
1000    }
1001
1002    #[test]
1003    fn calls_roundtrip_on_the_wire() {
1004        let call = CustodyCall::new(
1005            UseAttribution {
1006                run_id: "run-1".into(),
1007                actor: Some("deployer".into()),
1008                effect_key: None,
1009            },
1010            CustodyOp::Sign {
1011                credential: name("release_signing"),
1012                alg: SignatureAlg::Ed25519,
1013                derivation: vec![],
1014                payload_b64: "cGF5bG9hZA==".into(),
1015            },
1016        );
1017        let wire = serde_json::to_string(&call).expect("serialize");
1018        let back: CustodyCall = serde_json::from_str(&wire).expect("deserialize");
1019        assert_eq!(back, call);
1020        assert_eq!(back.op.operation(), Operation::Sign);
1021
1022        let reply = CustodyReply {
1023            use_id: "use-1".into(),
1024            rung: Rung::Process,
1025            degraded: true,
1026            outcome: Err(CustodyError::RungBelowFloor {
1027                required: Rung::Hardware,
1028                actual: Rung::Process,
1029            }),
1030        };
1031        let wire = serde_json::to_string(&reply).expect("serialize");
1032        let back: CustodyReply = serde_json::from_str(&wire).expect("deserialize");
1033        assert_eq!(back, reply);
1034    }
1035
1036    #[test]
1037    fn kind_operation_support_is_static() {
1038        assert!(CredentialKind::Bearer.supports(Operation::Request));
1039        // The DR's own example: `sign … with stripe_api` is rejectable.
1040        assert!(!CredentialKind::Bearer.supports(Operation::Sign));
1041        assert!(CredentialKind::Ed25519.supports(Operation::Sign));
1042        assert!(!CredentialKind::Ed25519.supports(Operation::Request));
1043        assert!(CredentialKind::AwsSigv4.supports(Operation::Request));
1044        assert!(CredentialKind::AwsSigv4.supports(Operation::Sign));
1045    }
1046}