pub mod canon;
#[cfg(target_family = "unix")]
pub mod client;
use std::fmt;
use serde::{Deserialize, Serialize};
pub const CUSTODY_PROTOCOL: &str = "whipplescript.custody.v1";
pub const CUSTODIAN_SOCKET_ENV: &str = "WHIPPLESCRIPT_CUSTODIAN_SOCKET";
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CredentialName(String);
impl CredentialName {
pub fn new(name: &str) -> Result<Self, String> {
if name.is_empty() {
return Err("credential name is empty".to_string());
}
let ok_segment = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
};
if !name.split('/').all(ok_segment) {
return Err(format!(
"invalid credential name {name:?}: segments must be non-empty [a-z0-9_-]"
));
}
Ok(Self(name.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn resource_id(&self) -> String {
format!("credential:{}", self.0)
}
pub fn from_resource_id(id: &str) -> Result<Self, String> {
match id.strip_prefix("credential:") {
Some(rest) => Self::new(rest),
None => Err(format!("not a credential resource id: {id:?}")),
}
}
}
impl fmt::Display for CredentialName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialRef {
Custodian(CredentialName),
LegacyEnv { var: String },
LegacyTag { tag: String },
}
impl CredentialRef {
pub fn parse(raw: &str) -> Result<Self, String> {
if raw.trim().is_empty() {
return Err("empty credential reference".to_string());
}
if let Some(var) = raw.strip_prefix("env:") {
if var.is_empty() {
return Err("env: credential reference names no variable".to_string());
}
return Ok(CredentialRef::LegacyEnv {
var: var.to_string(),
});
}
if let Some(rest) = raw.strip_prefix("credential:") {
if let Ok(name) = CredentialName::new(rest) {
return Ok(CredentialRef::Custodian(name));
}
return Ok(CredentialRef::LegacyTag {
tag: raw.to_string(),
});
}
if raw.starts_with("secret:") {
return Ok(CredentialRef::LegacyTag {
tag: raw.to_string(),
});
}
Err(format!(
"unrecognized credential reference {raw:?}: use `credential:<name>` (custodian \
entry) or the legacy `env:<VAR>` shim"
))
}
pub fn shim_rung(&self) -> (Rung, bool) {
(Rung::Process, true)
}
pub fn is_legacy(&self) -> bool {
!matches!(self, CredentialRef::Custodian(_))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CredentialKind {
Bearer,
Basic,
Raw,
HmacSha256,
Ed25519,
AwsSigv4,
JwtRs256,
}
impl CredentialKind {
pub fn as_str(&self) -> &'static str {
match self {
CredentialKind::Bearer => "bearer",
CredentialKind::Basic => "basic",
CredentialKind::Raw => "raw",
CredentialKind::HmacSha256 => "hmac-sha256",
CredentialKind::Ed25519 => "ed25519",
CredentialKind::AwsSigv4 => "aws-sigv4",
CredentialKind::JwtRs256 => "jwt-rs256",
}
}
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"bearer" => Ok(CredentialKind::Bearer),
"basic" => Ok(CredentialKind::Basic),
"raw" => Ok(CredentialKind::Raw),
"hmac-sha256" => Ok(CredentialKind::HmacSha256),
"ed25519" => Ok(CredentialKind::Ed25519),
"aws-sigv4" => Ok(CredentialKind::AwsSigv4),
"jwt-rs256" => Ok(CredentialKind::JwtRs256),
other => Err(format!("unknown credential kind {other:?}")),
}
}
pub fn supports(&self, op: Operation) -> bool {
match op {
Operation::Request => matches!(
self,
CredentialKind::Bearer
| CredentialKind::Basic
| CredentialKind::Raw
| CredentialKind::AwsSigv4
),
Operation::Sign | Operation::Verify => matches!(
self,
CredentialKind::HmacSha256
| CredentialKind::Ed25519
| CredentialKind::AwsSigv4
| CredentialKind::JwtRs256
),
Operation::Derive => matches!(
self,
CredentialKind::HmacSha256 | CredentialKind::AwsSigv4 | CredentialKind::Raw
),
Operation::Wrap | Operation::Unwrap => {
matches!(self, CredentialKind::Raw | CredentialKind::HmacSha256)
}
Operation::Mint => matches!(
self,
CredentialKind::Bearer | CredentialKind::Basic | CredentialKind::Raw
),
}
}
}
impl fmt::Display for CredentialKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Rung {
Process,
OsKeyring,
Hardware,
Remote,
}
impl Rung {
pub fn as_str(&self) -> &'static str {
match self {
Rung::Process => "process",
Rung::OsKeyring => "os-keyring",
Rung::Hardware => "hardware",
Rung::Remote => "remote",
}
}
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"process" | "r0" => Ok(Rung::Process),
"os-keyring" | "r1" => Ok(Rung::OsKeyring),
"hardware" | "r2" => Ok(Rung::Hardware),
"remote" | "r3" => Ok(Rung::Remote),
other => Err(format!("unknown sealing rung {other:?}")),
}
}
pub fn ladder_label(&self) -> &'static str {
match self {
Rung::Process => "r0",
Rung::OsKeyring => "r1",
Rung::Hardware => "r2",
Rung::Remote => "r3",
}
}
}
impl fmt::Display for Rung {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Operation {
Request,
Sign,
Verify,
Derive,
Wrap,
Unwrap,
Mint,
}
impl Operation {
pub const ALL: [Operation; 7] = [
Operation::Request,
Operation::Sign,
Operation::Verify,
Operation::Derive,
Operation::Wrap,
Operation::Unwrap,
Operation::Mint,
];
pub fn as_str(&self) -> &'static str {
match self {
Operation::Request => "request",
Operation::Sign => "sign",
Operation::Verify => "verify",
Operation::Derive => "derive",
Operation::Wrap => "wrap",
Operation::Unwrap => "unwrap",
Operation::Mint => "mint",
}
}
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"request" => Ok(Operation::Request),
"sign" => Ok(Operation::Sign),
"verify" => Ok(Operation::Verify),
"derive" => Ok(Operation::Derive),
"wrap" => Ok(Operation::Wrap),
"unwrap" => Ok(Operation::Unwrap),
"mint" => Ok(Operation::Mint),
other => Err(format!("unknown custody operation {other:?}")),
}
}
pub fn narrowable(&self) -> bool {
matches!(self, Operation::Request | Operation::Mint)
}
}
impl fmt::Display for Operation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PresentationForm {
Bearer,
Basic,
Raw,
}
impl PresentationForm {
pub fn as_str(&self) -> &'static str {
match self {
PresentationForm::Bearer => "bearer",
PresentationForm::Basic => "basic",
PresentationForm::Raw => "raw",
}
}
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"bearer" => Ok(PresentationForm::Bearer),
"basic" => Ok(PresentationForm::Basic),
"raw" => Ok(PresentationForm::Raw),
other => Err(format!("unknown presentation form {other:?}")),
}
}
}
impl fmt::Display for PresentationForm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
const SENTINEL_OPEN: &str = "{{whipplescript-credential:";
const SENTINEL_CLOSE: &str = "}}";
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Sentinel {
pub credential: CredentialName,
pub form: PresentationForm,
}
impl Sentinel {
pub fn new(credential: CredentialName, form: PresentationForm) -> Self {
Self { credential, form }
}
pub fn render(&self) -> String {
format!(
"{SENTINEL_OPEN}{}:{}{SENTINEL_CLOSE}",
self.credential, self.form
)
}
pub fn parse(text: &str) -> Result<Self, String> {
let inner = text
.strip_prefix(SENTINEL_OPEN)
.and_then(|t| t.strip_suffix(SENTINEL_CLOSE))
.ok_or_else(|| format!("not a credential sentinel: {text:?}"))?;
let (name, form) = inner
.rsplit_once(':')
.ok_or_else(|| format!("malformed credential sentinel: {text:?}"))?;
Ok(Self {
credential: CredentialName::new(name)?,
form: PresentationForm::parse(form)?,
})
}
pub fn find_all(text: &str) -> Result<Vec<(std::ops::Range<usize>, Sentinel)>, String> {
let mut out = Vec::new();
let mut at = 0usize;
while let Some(rel) = text[at..].find(SENTINEL_OPEN) {
let start = at + rel;
let close_rel = text[start..]
.find(SENTINEL_CLOSE)
.ok_or_else(|| "unterminated credential sentinel".to_string())?;
let end = start + close_rel + SENTINEL_CLOSE.len();
let sentinel = Sentinel::parse(&text[start..end])?;
out.push((start..end, sentinel));
at = end;
}
Ok(out)
}
}
impl fmt::Display for Sentinel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EgressRequest {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_b64: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EgressResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_b64: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SignatureAlg {
HmacSha256,
Ed25519,
RsaSha256,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope {
pub credential: CredentialName,
pub context: String,
pub label: serde_json::Value,
pub nonce_b64: String,
pub ciphertext_b64: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MintExtraction {
pub token_path: String,
#[serde(default)]
pub public_paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
pub enum CustodyOp {
Request {
credential: CredentialName,
request: EgressRequest,
slots: usize,
},
Sign {
credential: CredentialName,
alg: SignatureAlg,
#[serde(default)]
derivation: Vec<String>,
payload_b64: String,
},
Verify {
credential: CredentialName,
alg: SignatureAlg,
payload_b64: String,
signature_b64: String,
},
Derive {
credential: CredentialName,
context: String,
},
Wrap {
credential: CredentialName,
plaintext_b64: String,
label: serde_json::Value,
context: String,
},
Unwrap {
credential: CredentialName,
envelope: Envelope,
context: String,
},
Mint {
credential: CredentialName,
scope: Vec<String>,
ttl_secs: u64,
exchange: EgressRequest,
extraction: MintExtraction,
exchange_slots: usize,
},
}
impl CustodyOp {
pub fn operation(&self) -> Operation {
match self {
CustodyOp::Request { .. } => Operation::Request,
CustodyOp::Sign { .. } => Operation::Sign,
CustodyOp::Verify { .. } => Operation::Verify,
CustodyOp::Derive { .. } => Operation::Derive,
CustodyOp::Wrap { .. } => Operation::Wrap,
CustodyOp::Unwrap { .. } => Operation::Unwrap,
CustodyOp::Mint { .. } => Operation::Mint,
}
}
pub fn credential(&self) -> &CredentialName {
match self {
CustodyOp::Request { credential, .. }
| CustodyOp::Sign { credential, .. }
| CustodyOp::Verify { credential, .. }
| CustodyOp::Derive { credential, .. }
| CustodyOp::Wrap { credential, .. }
| CustodyOp::Unwrap { credential, .. }
| CustodyOp::Mint { credential, .. } => credential,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UseAttribution {
pub run_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustodyCall {
pub protocol: String,
pub attribution: UseAttribution,
#[serde(flatten)]
pub op: CustodyOp,
}
impl CustodyCall {
pub fn new(attribution: UseAttribution, op: CustodyOp) -> Self {
Self {
protocol: CUSTODY_PROTOCOL.to_string(),
attribution,
op,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "lowercase")]
pub enum CustodyOk {
Requested {
response: EgressResponse,
},
Signed {
signature_b64: String,
},
Verified {
valid: bool,
},
Derived {
credential: CredentialName,
},
Wrapped {
envelope: Envelope,
},
Unwrapped {
plaintext_b64: String,
label: serde_json::Value,
},
Minted {
credential: CredentialName,
fingerprint: String,
public: serde_json::Value,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "error", rename_all = "kebab-case")]
pub enum CustodyError {
UnknownCredential {
credential: CredentialName,
},
KindMismatch {
credential: CredentialName,
kind: CredentialKind,
operation: Operation,
},
OperationNotGranted {
credential: CredentialName,
operation: Operation,
},
ScopeRefused {
credential: CredentialName,
detail: String,
},
RungBelowFloor {
required: Rung,
actual: Rung,
},
Revoked {
credential: CredentialName,
},
BudgetExhausted {
credential: CredentialName,
},
EnvelopeRefused,
EgressFailed {
detail: String,
},
Backend {
detail: String,
},
}
impl fmt::Display for CustodyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CustodyError::UnknownCredential { credential } => {
write!(f, "unknown credential {credential}")
}
CustodyError::KindMismatch {
credential,
kind,
operation,
} => write!(
f,
"credential {credential} has kind {kind}, which does not support {operation}"
),
CustodyError::OperationNotGranted {
credential,
operation,
} => write!(f, "{operation} on {credential} is not granted"),
CustodyError::ScopeRefused { credential, detail } => {
write!(f, "scope refused for {credential}: {detail}")
}
CustodyError::RungBelowFloor { required, actual } => write!(
f,
"sealing rung {} is below the required floor {}",
actual.ladder_label(),
required.ladder_label()
),
CustodyError::Revoked { credential } => write!(f, "credential {credential} is revoked"),
CustodyError::BudgetExhausted { credential } => {
write!(f, "use budget exhausted for {credential}")
}
CustodyError::EnvelopeRefused => f.write_str("envelope refused"),
CustodyError::EgressFailed { detail } => write!(f, "egress failed: {detail}"),
CustodyError::Backend { detail } => write!(f, "custodian backend fault: {detail}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustodyReply {
pub use_id: String,
pub rung: Rung,
pub degraded: bool,
pub outcome: Result<CustodyOk, CustodyError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportError {
Unavailable(String),
Protocol(String),
}
impl fmt::Display for TransportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TransportError::Unavailable(d) => write!(f, "custodian unavailable: {d}"),
TransportError::Protocol(d) => write!(f, "custody protocol error: {d}"),
}
}
}
impl std::error::Error for TransportError {}
pub trait CustodyTransport: Send + Sync {
fn call(&self, call: CustodyCall) -> Result<CustodyReply, TransportError>;
}
#[cfg(test)]
mod tests {
use super::*;
fn name(s: &str) -> CredentialName {
CredentialName::new(s).expect("valid name")
}
#[test]
fn rungs_are_ordered() {
assert!(Rung::Process < Rung::OsKeyring);
assert!(Rung::OsKeyring < Rung::Hardware);
assert!(Rung::Hardware < Rung::Remote);
assert_eq!(Rung::parse("r2").expect("parse"), Rung::Hardware);
assert_eq!(Rung::parse("hardware").expect("parse"), Rung::Hardware);
}
#[test]
fn resource_identity_is_backend_free() {
let n = name("acme/stripe-live");
assert_eq!(n.resource_id(), "credential:acme/stripe-live");
assert_eq!(
CredentialName::from_resource_id("credential:acme/stripe-live").expect("roundtrip"),
n
);
assert!(CredentialName::from_resource_id("vault:acme/stripe-live").is_err());
assert!(CredentialName::new("Bad Name").is_err());
assert!(CredentialName::new("trailing/").is_err());
}
#[test]
fn sentinel_roundtrip_and_scan() {
let s = Sentinel::new(name("stripe_api"), PresentationForm::Bearer);
assert_eq!(s.render(), "{{whipplescript-credential:stripe_api:bearer}}");
assert_eq!(Sentinel::parse(&s.render()).expect("parse"), s);
let header = format!("Bearer {}", s.render());
let found = Sentinel::find_all(&header).expect("scan");
assert_eq!(found.len(), 1);
assert_eq!(found[0].1, s);
assert_eq!(&header[found[0].0.clone()], s.render());
let two = format!(
"{} and {}",
Sentinel::new(name("a"), PresentationForm::Raw).render(),
Sentinel::new(name("b"), PresentationForm::Basic).render()
);
assert_eq!(Sentinel::find_all(&two).expect("scan").len(), 2);
assert!(Sentinel::find_all("{{whipplescript-credential:oops").is_err());
assert!(Sentinel::find_all("{{whipplescript-credential:UPPER:bearer}}").is_err());
assert!(Sentinel::find_all("no sentinels here")
.expect("scan")
.is_empty());
}
#[test]
fn operation_grant_classes_match_dr0053_s14() {
let narrowable: Vec<Operation> = Operation::ALL
.iter()
.copied()
.filter(Operation::narrowable)
.collect();
assert_eq!(narrowable, vec![Operation::Request, Operation::Mint]);
}
#[test]
fn credential_refs_unify_with_legacy_spellings_tagged_degraded() {
assert_eq!(
CredentialRef::parse("credential:acme/stripe-live").expect("parse"),
CredentialRef::Custodian(name("acme/stripe-live"))
);
for legacy in [
"env:OPENAI_API_KEY",
"secret:claude",
"credential:account:openai",
] {
let parsed = CredentialRef::parse(legacy).expect("legacy parses");
assert!(parsed.is_legacy(), "{legacy} must be legacy");
assert_eq!(parsed.shim_rung(), (Rung::Process, true));
}
assert!(!CredentialRef::parse("credential:model")
.expect("parse")
.is_legacy());
assert!(CredentialRef::parse("sk_live_plaintext").is_err());
assert!(CredentialRef::parse("env:").is_err());
}
#[test]
fn there_is_no_get_on_the_wire() {
let get = serde_json::json!({
"protocol": CUSTODY_PROTOCOL,
"attribution": { "run_id": "r1" },
"op": "get",
"credential": "stripe_api",
});
assert!(serde_json::from_value::<CustodyCall>(get).is_err());
}
#[test]
fn calls_roundtrip_on_the_wire() {
let call = CustodyCall::new(
UseAttribution {
run_id: "run-1".into(),
actor: Some("deployer".into()),
effect_key: None,
},
CustodyOp::Sign {
credential: name("release_signing"),
alg: SignatureAlg::Ed25519,
derivation: vec![],
payload_b64: "cGF5bG9hZA==".into(),
},
);
let wire = serde_json::to_string(&call).expect("serialize");
let back: CustodyCall = serde_json::from_str(&wire).expect("deserialize");
assert_eq!(back, call);
assert_eq!(back.op.operation(), Operation::Sign);
let reply = CustodyReply {
use_id: "use-1".into(),
rung: Rung::Process,
degraded: true,
outcome: Err(CustodyError::RungBelowFloor {
required: Rung::Hardware,
actual: Rung::Process,
}),
};
let wire = serde_json::to_string(&reply).expect("serialize");
let back: CustodyReply = serde_json::from_str(&wire).expect("deserialize");
assert_eq!(back, reply);
}
#[test]
fn kind_operation_support_is_static() {
assert!(CredentialKind::Bearer.supports(Operation::Request));
assert!(!CredentialKind::Bearer.supports(Operation::Sign));
assert!(CredentialKind::Ed25519.supports(Operation::Sign));
assert!(!CredentialKind::Ed25519.supports(Operation::Request));
assert!(CredentialKind::AwsSigv4.supports(Operation::Request));
assert!(CredentialKind::AwsSigv4.supports(Operation::Sign));
}
}