Tenuo Wire Format Specification
Version: 1.0
Status: Normative
Date: 2026-01-10
Related Documents:
- protocol-spec-v1.md - Protocol Specification (concepts, invariants, algorithms)
- test-vectors.md - Byte-exact test vectors for validation
Overview
This specification defines the wire format for Tenuo warrants. These decisions are baked into v0.1 and cannot change without a major version bump.
Design principles:
- Verify before deserialize - Check signatures against raw bytes, not re-serialized data
- Fail closed - Unknown fields/types reject, not ignore
- Extensibility hooks - Add fields now, implement features later
- Algorithm agility - Don’t hardcode key sizes or algorithms
1. Envelope Pattern
Warrants use an envelope structure that separates the signed payload from the signature.
/// Outer envelope (what goes on the wire)
pub struct SignedWarrant {
/// Envelope format version
pub envelope_version: u8,
/// Raw CBOR bytes of WarrantPayload
pub payload: Vec<u8>,
/// Signature computed over `payload` bytes
pub signature: Signature,
}
/// Inner payload (deserialized from SignedWarrant.payload)
pub struct WarrantPayload {
pub version: u8,
pub id: WarrantId,
pub warrant_type: WarrantType,
pub tools: BTreeMap<String, ConstraintSet>,
pub holder: PublicKey,
pub issuer: PublicKey,
pub issued_at: u64,
pub expires_at: u64,
pub max_depth: u8,
pub parent_hash: Option<[u8; 32]>, // SHA256(parent payload bytes)
pub extensions: BTreeMap<String, Vec<u8>>,
// Auth-critical optional fields (validated like core fields)
pub issuable_tools: Option<Vec<String>>,
pub max_issue_depth: Option<u32>,
pub constraint_bounds: Option<ConstraintSet>,
pub required_approvers: Option<Vec<PublicKey>>,
pub min_approvals: Option<u32>,
pub clearance: Option<Clearance>,
pub depth: u32,
}
Why an envelope?
The problem with in-band signatures:
BAD: In-band signature inside the struct
Signer Verifier
| |
| serialize(fields 0-8) |
| sign(bytes) -> sig |
| serialize(fields 0-8 + sig) |
| |
|---------- wire bytes ----------->|
| |
| deserialize(all)
| strip signature field
| RE-serialize(fields 0-8) <- DANGER
| verify(new_bytes, sig)
If the verifier’s CBOR library serializes differently than the signer’s (different integer widths, array encodings, map ordering), the bytes differ and verification fails. This is a canonicalization bug. It is subtle, hard to debug, breaks cross-language compatibility.
The envelope solution:
GOOD: Envelope with signature outside the payload
Signer Verifier
| |
| serialize(payload) -> bytes |
| sign(bytes) -> sig |
| envelope(bytes, sig) |
| |
|---------- wire bytes ----------->|
| |
| unwrap -> (bytes, sig)
| verify(bytes, sig) <- SAME BYTES
| deserialize(bytes) -> payload
The verifier checks the signature against the exact bytes that were signed. No re-serialization. No canonicalization dependency.
Additional benefit: Signature verification happens before expensive deserialization. Invalid signatures are rejected without parsing the payload.
2. Verification Flow
fn verify(
signed: &SignedWarrant,
trusted_roots: &[PublicKey],
) -> Result<WarrantPayload, VerificationError> {
// 1. Check envelope version
if signed.envelope_version != 1 {
return Err(VerificationError::UnsupportedEnvelopeVersion);
}
// 2. Extract issuer public key from raw payload
// (minimal parsing, just enough to get the key)
let issuer = extract_issuer(&signed.payload)?;
// 3. Verify signature over the domain-separated preimage
// (see §4 "Signature domain separation" for normative details)
let preimage = build_preimage(signed.envelope_version, &signed.payload);
issuer.verify(&preimage, &signed.signature)?;
// 4. Now safe to deserialize (signature is valid)
let payload: WarrantPayload = cbor::deserialize(&signed.payload)?;
// 5. Check payload version
if payload.version != 1 {
return Err(VerificationError::UnsupportedPayloadVersion);
}
// 6. Validate trust chain, TTL, constraints, etc.
validate_payload(&payload, trusted_roots)?;
Ok(payload)
}
Testable Invariants (Chain Attenuation Rules)
Every implementation MUST verify these properties. Tests should reference these invariants by number.
I1: Delegation Authority
child.issuer == parent.holder
Rationale: The parent’s holder is the entity authorized to delegate. This establishes clear audit trail: “parent.holder delegated to child.holder”.
Why this matters:
- Audit clarity: “Who authorized this delegation?”
- Trust model: Authority flows from issuer (who authorized) to holder (who can use)
- Industry standard: Matches X.509, Macaroons, SPIFFE, UCAN
Enforcement points:
- Builder:
AttenuationBuilder::build()MUST use parent’s holder keypair to sign - Verifier:
verify_chain_link()MUST checkchild.issuer() == parent.holder()
Test requirement:
assert_eq!(child.issuer(), parent.holder(), "Invariant I1 violated");
I2: Depth Monotonicity
child.depth == parent.depth + 1
child.depth <= MAX_DELEGATION_DEPTH (64)
child.depth <= parent.max_depth
Rationale: Prevents unbounded chains (DoS) and enforces delegation limits.
Depth semantics: max_depth is an absolute ceiling, not a remaining count. A warrant is terminal (cannot delegate further) when depth >= max_depth. The conditions above show validity; the verifier rejects when child.depth > parent.max_depth.
Enforcement points:
- Builder: Increment depth, check against limits
- Verifier: Validate depth increment and limits
I3: TTL Monotonicity
child.expires_at <= parent.expires_at
child.ttl <= MAX_WARRANT_TTL_SECS (90 days)
Rationale: Authority cannot outlive its source. Prevents time-based privilege escalation.
Enforcement points:
- Builder: Cap child TTL at parent’s remaining time
- Verifier: Check expiration doesn’t exceed parent
I4: Capability Monotonicity
child.tools ⊆ parent.tools
∀ tool ∈ child.tools: child.constraints[tool] ⊑ parent.constraints[tool]
Rationale: Principle of Least Authority (POLA) - capabilities only shrink.
Enforcement points:
- Builder: Validate tool subset and constraint narrowing
- Verifier: Check monotonicity for each tool
I5: Cryptographic Linkage
child.parent_hash == SHA256(parent.payload_bytes)
verify(parent.issuer, parent.signature_preimage, parent.signature)
verify(child.issuer, child.signature_preimage, child.signature)
Rationale: Prevents chain tampering and warrant forgery.
Enforcement points:
- Builder: Compute parent_hash from parent payload
- Verifier: Verify hash matches and signatures valid
I6: Holder Binding (Proof-of-Possession)
pop_signature = sign(holder_private_key, challenge)
verify(warrant.holder, challenge, pop_signature)
Rationale: Prevents warrant theft - holder must prove key possession.
Enforcement points:
- Execution: Holder creates PoP signature for each action
- Verifier: Validate PoP against warrant.holder (not issuer!)
Verification Checklist
Implementations MUST verify ALL invariants. Missing checks create security vulnerabilities.
| Invariant | Builder | Verifier | Test |
|---|---|---|---|
| I1: Delegation Authority | Yes: Sign with parent.holder | Yes: Check issuer == parent.holder | Required |
| I2: Depth Monotonicity | Yes: Increment & validate | Yes: Check depth rules | Required |
| I3: TTL Monotonicity | Yes: Cap at parent TTL | Yes: Check expiration | Required |
| I4: Capability Monotonicity | Yes: Validate narrowing | Yes: Check tool/constraint subset | Required |
| I5: Cryptographic Linkage | Yes: Compute parent_hash | Yes: Verify hash & signatures | Required |
| I6: Holder Binding | N/A | Yes: Verify PoP signature | Required |
Common Implementation Errors
WRONG - Error 1: Child signs own warrant
// WRONG - violates I1
let child = parent.grant_builder()
.holder(child_key.public_key())
.build(&child_key); // Child signs - WRONG!
CORRECT:
let child = parent.grant_builder()
.holder(child_key.public_key())
.build(&parent_key); // Parent's holder signs - CORRECT
WRONG - Error 2: Missing issuer check in verifier
// WRONG - violates I1 verification
fn verify_chain_link(parent, child) {
check_parent_hash(parent, child); // OK
check_depth(parent, child); // OK
// Missing: check child.issuer == parent.holder <- BUG
}
CORRECT:
fn verify_chain_link(parent, child) {
check_parent_hash(parent, child);
check_depth(parent, child);
assert_eq!(child.issuer(), parent.holder()); // I1 check
}
WRONG - Error 3: Verifying PoP against issuer
// WRONG - violates I6
verify(child.issuer(), pop_challenge, pop_sig); // WRONG
CORRECT:
verify(child.holder(), pop_challenge, pop_sig); // CORRECT
Note: For details on how delegation is cryptographically proven, see the “Cryptographic Linkage (I5)” section in protocol-spec-v1.md.
3. Version Fields
Two version fields for independent evolution:
| Field | Location | Purpose |
|---|---|---|
envelope_version |
SignedWarrant | Envelope structure changes |
version |
WarrantPayload | Payload schema changes |
pub struct SignedWarrant {
/// Envelope version. Currently 1.
/// Increment if: signature algorithm selection changes,
/// envelope fields change, or wrapper structure changes.
pub envelope_version: u8,
// ...
}
pub struct WarrantPayload {
/// Payload version. Currently 1.
/// Increment if: payload fields change, semantics change,
/// or new required fields are added.
pub version: u8,
// ...
}
Version handling rules:
| Version seen | Behavior |
|---|---|
0 |
Invalid, reject |
1 |
Current, process normally |
2+ |
Unknown, reject (until verifier upgraded) |
Rationale: Envelope version lets us change the crypto wrapper (e.g., switch to COSE_Sign1) without touching payload parsing. Payload version lets us change warrant semantics without touching signature verification.
4. Algorithm Agility
Public keys and signatures are self-describing.
#[repr(u8)]
pub enum Algorithm {
/// Ed25519: 32-byte public keys, 64-byte signatures
Ed25519 = 1,
// Reserved for future use:
// Ed448 = 2,
// Dilithium2 = 3, // Post-quantum
// Dilithium3 = 4,
}
pub struct PublicKey {
/// Algorithm identifier
pub algorithm: Algorithm,
/// Raw key bytes (length depends on algorithm)
pub bytes: Vec<u8>,
}
pub struct Signature {
/// Algorithm identifier (must match issuer's public key)
pub algorithm: Algorithm,
/// Raw signature bytes (length depends on algorithm)
pub bytes: Vec<u8>,
}
Signature domain separation
- The signature preimage MUST be
b"tenuo-warrant-v1" || envelope_version || payload_bytes. - Algorithms that support contexts (e.g., Ed25519ctx/Ed25519ph) MUST use the context string
tenuo-warrant-v1. - Verifiers MUST reject signatures that omit the required domain separation or that use a different context.
- Verification step: reconstruct the preimage from the received
envelope_versionand rawpayloadbytes; verify the signature against that exact preimage before deserializing.
Validation rules:
| Check | Failure |
|---|---|
| Unknown algorithm ID | Reject |
| Key length doesn’t match algorithm | Reject |
| Signature algorithm ≠ key algorithm | Reject |
Key sizes by algorithm:
| Algorithm | Public Key | Signature |
|---|---|---|
| Ed25519 | 32 bytes | 64 bytes |
| Dilithium2 | 1,312 bytes | 2,420 bytes |
Rationale: Hardcoding [u8; 32] for keys prevents migration to post-quantum algorithms. The extra byte for algorithm ID costs nothing and enables future-proofing.
5. Timestamps
All timestamps are Unix seconds (not milliseconds).
pub struct WarrantPayload {
/// When the warrant was issued (Unix seconds)
pub issued_at: u64,
/// When the warrant expires (Unix seconds)
pub expires_at: u64,
// ...
}
Rules:
| Field | Validation |
|---|---|
issued_at |
Must be ≤ current time + clock tolerance |
expires_at |
Must be > current time |
expires_at |
Must be > issued_at |
expires_at |
Must be ≤ parent’s expires_at (if attenuated) |
Why seconds, not milliseconds:
u64seconds covers 584 billion years - sufficient- Simpler mental math when debugging
- Matches Unix timestamp convention
- Avoids confusion between seconds/milliseconds
Clock tolerance for TTL validation: ±30 seconds to handle clock skew.
PoP replay window: 120 seconds (30s window × 4 max windows) - see Proof-of-Possession section.
Integer Value Limits
All integer values in warrants (timestamps, constraint bounds, depth, counts, etc.) MUST fit within the signed 64-bit range: −2^63 to 2^63−1.
Rules:
| Scenario | Behavior |
|---|---|
| Integer within i64 range | Valid |
| Integer outside i64 range | Reject warrant |
| CBOR bignum (tag 2/3) | Reject warrant |
Rationale:
- JavaScript safety: JS
Numberonly has safe integers up to 2^53; WASM bindings use BigInt for i64 - Cross-language consistency: Rust uses
i64, Python has arbitrary precision, Go usesint64 - CBOR allows arbitrary precision: Without this limit, a malicious warrant could contain 128-bit integers that break some implementations
Large value escape hatch: Integers outside i64 range (e.g., snowflake IDs, UUIDs as integers) MUST be encoded as bytes (big-endian) or string. Verifiers will treat these as opaque values for Exact/OneOf matching.
Note on Range constraints: Range bounds use f64 internally, which loses precision for integers > 2^53. For snowflake IDs or other large integers, use Exact or OneOf constraints instead.
6. Constraint Types
#[repr(u8)]
pub enum ConstraintType {
// Standard constraints (1-127)
Exact = 1,
Pattern = 2,
Range = 3,
OneOf = 4,
Regex = 5,
// 6 is reserved for future IntRange with i64 bounds
NotOneOf = 7,
Cidr = 8,
UrlPattern = 9,
Contains = 10,
Subset = 11,
All = 12,
Any = 13,
Not = 14,
Cel = 15,
Wildcard = 16,
// Future standard types: 17-127
// Experimental / private use (128-255)
// See "Constraint Type Ranges" below
}
pub enum Constraint {
/// Exact string match
Exact(String),
/// Glob pattern (*, **, ?)
Pattern(String),
/// Numeric range (uses f64; see precision note below)
Range {
min: Option<f64>,
max: Option<f64>,
min_inclusive: bool,
max_inclusive: bool,
},
/// Value must be in list
OneOf(Vec<String>),
/// Regular expression match
Regex(String),
/// Value must NOT be in excluded set
NotOneOf(Vec<String>),
/// IP/network must be within CIDR
Cidr(String),
/// URL must match pattern (scheme/host/path)
UrlPattern(String),
/// List must contain all listed values
Contains(Vec<String>),
/// List must be a subset of allowed values
Subset(Vec<String>),
/// All nested constraints must pass (AND)
All(Vec<Constraint>),
/// At least one nested constraint must pass (OR)
Any(Vec<Constraint>),
/// Negation (NOT) of a constraint
Not(Box<Constraint>),
/// CEL expression (must return bool)
Cel(String),
/// Wildcard (matches anything)
Wildcard,
/// Secure path containment (prevents path traversal)
Subpath {
root: String,
case_sensitive: bool, // Default: true
allow_equal: bool, // Default: true
},
/// SSRF-safe URL validation
UrlSafe {
schemes: Vec<String>, // Default: ["http", "https"]
allow_domains: Option<Vec<String>>, // Domain allowlist
allow_ports: Option<Vec<u16>>, // Port allowlist
block_private: bool, // Default: true
block_loopback: bool, // Default: true
block_metadata: bool, // Default: true
block_reserved: bool, // Default: true
block_internal_tlds: bool, // Default: false
},
/// Unknown constraint type (deserialized but not understood)
Unknown {
type_id: u8,
payload: Vec<u8>,
},
}
Wire type IDs and serialization (standard 1–127):
All constraints serialize as [type_id, value] tuples. The value is the serde serialization of the constraint struct.
| ID | Type | Value Shape | Notes |
|---|---|---|---|
| 1 | Exact | {value: any} |
Exact value match |
| 2 | Pattern | {pattern: string} |
Glob (*, ?, **) |
| 3 | Range | {min?: f64, max?: f64} |
Numeric bounds |
| 4 | OneOf | {values: [any]} |
Allowed set |
| 5 | Regex | {pattern: string} |
Regex pattern |
| 6 | (reserved) | - | Reserved for IntRange |
| 7 | NotOneOf | {excluded: [any]} |
Excluded set |
| 8 | Cidr | {network: string} |
CIDR notation |
| 9 | UrlPattern | {pattern: string} |
URL pattern |
| 10 | Contains | {required: [any]} |
List must contain all |
| 11 | Subset | {allowed: [any]} |
List must be subset |
| 12 | All | {constraints: [Constraint]} |
AND of children |
| 13 | Any | {constraints: [Constraint]} |
OR of children |
| 14 | Not | {constraint: Constraint} |
Negation |
| 15 | Cel | {expr: string} |
CEL expression |
| 16 | Wildcard | null |
Matches anything |
| 17 | Subpath | {root: string, case_sensitive?: bool, allow_equal?: bool} |
Path containment |
| 18 | UrlSafe | {schemes?: [string], allow_domains?: [string], ...} |
SSRF protection |
Range precision note: Range (ID 3) uses f64 bounds. Converting i64 values larger than 2^53 (9,007,199,254,740,992) to f64 loses precision. For practical use cases (monetary amounts, counts, file sizes), this is not a concern. For very large integer constraints (e.g., snowflake IDs), use Exact or OneOf instead.
Reserved ID 6: Reserved for a future IntRange type with i64 bounds if precise large-integer range comparisons are needed. Currently, Range (ID 3) handles both integer and float values with f64 precision.
Attenuation semantics: For containment/attenuation rules (what “stricter” means), see the Constraint Lattice in protocol-spec-v1.md. Minimal reminders for some types:
NotOneOf: child must exclude >= parent’s exclusions (never remove exclusions).Contains: child must require a superset of parent’s required elements.Subset: child’s allowed set must be ⊆ parent’s allowed set.All: child may add more clauses; existing clauses must not be weakened.Any: child may remove clauses; remaining clauses must not be weakened.Not: negation of a stricter constraint remains stricter only if the inner constraint is stricter.Cel: child must conjoin with parent (logical AND); never replace/loosen parent expression.
Constraint Type Ranges
| Range | Purpose |
|---|---|
| 0 | Reserved (invalid) |
| 1–16 | Core constraints (implemented) |
| 17–32 | Reserved for common patterns |
| 33–127 | Future standard constraints |
| 128–255 | Experimental / private use |
Reserved IDs (17-32):
| ID | Reserved For | Status |
|---|---|---|
| 17 | TimeWindow | Planned: day/hour-of-week constraints |
| 18 | GeoFence | Planned: lat/lon bounding box |
| 19 | RateLimit | Planned: call frequency limits |
| 20-32 | Future patterns | Unassigned |
Standard range (1–127): Constraints defined in this specification and future Tenuo releases. All compliant verifiers must implement these.
Experimental range (128–255): For internal testing, proprietary extensions, or organization-specific constraints. These fail authorization on standard verifiers. Use for:
- Testing new constraint types before proposing standardization
- Building proprietary extensions that don’t need interoperability
- Organization-internal constraints
Unknown constraint handling
When a verifier encounters an unrecognized constraint type ID, it must:
- Deserialize into
Constraint::Unknown { type_id, payload } - Preserve the data (don’t strip it)
- Fail authorization -
Unknown.check()always returnsfalse
impl Constraint {
pub fn check(&self, value: &Value) -> bool {
match self {
Self::Exact(expected) => value.as_str() == Some(expected),
Self::Pattern(pattern) => glob_match(pattern, value),
Self::Range { min, max, .. } => check_range(value, *min, *max),
Self::OneOf(allowed) => allowed.contains(&value.to_string()),
Self::Regex(pattern) => regex_match(pattern, value),
// ... other constraint types ...
// Unknown constraints ALWAYS fail (fail closed)
Self::Unknown { .. } => false,
}
}
}
Why fail closed:
| Approach | Problem |
|---|---|
| Ignore unknown | Security hole - skips restrictions |
| Crash on unknown | Brittle - can’t deploy new constraints gradually |
| Strip unknown | Breaks signature - payload was signed with them |
| Fail closed | Safe and forward-compatible |
Numeric constraint domains
Rangeusesf64bounds with configurable inclusivity (min_inclusive,max_inclusive).- NaN and infinite values are invalid and must be rejected.
- For integers larger than 2^53, use
ExactorOneOfto avoid precision loss.
Deployment scenario (example):
- v0.2 adds a new constraint type
GeoFence(type ID = 17) - Issuer creates warrant with
GeoFence("us-east-1") - Old verifier (v0.1) sees type ID 17, deserializes as
Unknown - Authorization check fails (safe default)
- Old verifier upgraded to v0.2, now understands
GeoFence - Authorization check passes
7. Tool-Scoped Constraints
Constraints are scoped per-tool, not global.
pub struct WarrantPayload {
/// Map of tool name to constraints for that tool
pub tools: BTreeMap<String, ConstraintSet>,
// ...
}
pub struct ConstraintSet {
/// Map of argument name to constraint
pub constraints: BTreeMap<String, Constraint>,
}
Example:
let payload = WarrantPayload {
tools: btreemap! {
"read_file" => ConstraintSet {
constraints: btreemap! {
"path" => Constraint::Pattern("/data/*"),
},
},
"search" => ConstraintSet {
constraints: btreemap! {
"query" => Constraint::Pattern("*public*"),
},
},
"ping" => ConstraintSet {
constraints: btreemap! {}, // Explicitly unconstrained
},
},
// ...
};
Rules:
| Scenario | Behavior |
|---|---|
| Tool in warrant, all constraints pass | Authorized |
| Tool in warrant, constraint fails | Denied |
| Tool not in warrant | Denied |
| Tool in warrant with empty constraints | Authorized (explicitly unconstrained) |
Rationale: Prevents ambiguity when tools have different argument schemas. A path constraint on read_file shouldn’t silently skip when search (which has no path argument) is called.
8. Extensions Bag
A signed-but-ignored metadata field for application data.
pub struct WarrantPayload {
/// Application metadata. Signed but not interpreted by Tenuo.
pub extensions: BTreeMap<String, Vec<u8>>,
// ...
}
Rules:
- Extensions are included in signature (part of payload)
- Core verifier never interprets extension contents
- Unknown keys are preserved, not stripped
- Empty map is valid (and default)
- Values are raw bytes - applications parse them
Reserved key prefixes:
| Prefix | Owner |
|---|---|
tenuo:* |
Reserved for future Tenuo use |
| Other | Application-defined |
Recommended key format: Reverse domain notation (com.example.trace_id)
Example use cases:
extensions: btreemap! {
"com.example.trace_id" => b"abc123".to_vec(),
"com.example.billing_tag" => b"team-ml".to_vec(),
"com.example.request_id" => uuid.as_bytes().to_vec(),
}
Why Vec<u8> instead of String or JSON:
- Applications can embed any format (Protobuf, JSON, CBOR, encrypted blobs)
- No parsing overhead in Tenuo
- No charset/encoding issues
- Tenuo doesn’t need to understand the data, just sign it
9. Reserved Tool Namespaces
The tenuo: tool name prefix is reserved for framework use.
impl WarrantPayload {
pub fn validate(&self) -> Result<(), ValidationError> {
for tool in self.tools.keys() {
if tool.starts_with("tenuo:") {
return Err(ValidationError::ReservedToolName(tool.clone()));
}
}
Ok(())
}
}
Reserved prefixes:
| Prefix | Purpose |
|---|---|
tenuo: |
Future framework features |
Potential future uses:
tenuo:revoke- Inline revocation directivetenuo:require_mfa- Enforcement flagtenuo:audit- Force audit log entry
Rationale: Prevents collision between user-defined tools and future framework features, while staying minimally opinionated about naming conventions.
10. Serialization Format
Warrants are serialized as CBOR (RFC 8949).
Envelope (SignedWarrant):
CBOR Array [
0: envelope_version (u8),
1: payload (bytes),
2: signature (Signature),
]
Signature:
CBOR Array [
0: algorithm (u8),
1: bytes (bytes),
]
PublicKey:
CBOR Array [
0: algorithm (u8),
1: bytes (bytes),
]
WarrantId:
CBOR Bytes (length = 16) // UUID bytes, big-endian
WarrantType:
CBOR Unsigned integer (u8) // enumerated as in code
Payload (WarrantPayload):
CBOR Map {
0: version (u8),
1: id (bytes, 16),
2: warrant_type (u8),
3: tools (map<string, constraint_set>),
4: holder (public_key),
5: issuer (public_key),
6: issued_at (u64),
7: expires_at (u64),
8: max_depth (u8),
9: parent_hash (bytes, optional) // SHA256(parent payload bytes)
10: extensions (map<string, bytes>),
// Auth-critical additional fields (validated like core fields)
11: issuable_tools (array<string>, optional),
12: (reserved for future use),
13: max_issue_depth (u32, optional),
14: constraint_bounds (constraint_set, optional),
15: required_approvers (array<public_key>, optional),
16: min_approvals (u32, optional),
17: clearance (u8 enum, optional),
18: depth (u32, default=0),
}
Metadata (not auth-critical):
session_id,agent_idare carried inextensionsunder reserved keys:tenuo.session_id,tenuo.agent_id.
Rules:
- Envelope uses array (fixed field order)
- Payload uses map with integer keys (allows sparse fields)
BTreeMapfor deterministic key ordering within maps- Unknown payload keys MUST be rejected unless they are under
extensions - Senders MUST NOT produce duplicate map keys (verifier behavior is undefined per RFC 8949 §5.6)
- Deterministic CBOR (RFC 8949) MUST be used: no indefinite-length items; canonical map key ordering; shortest-length integer encodings
[!NOTE] Duplicate CBOR map keys: Senders MUST NOT produce. Verifier behavior is undefined (RFC 8949 §5.6). We do not mandate rejection because: (1) many CBOR libraries lack duplicate detection, and (2) malicious issuer is out of scope. Implementations SHOULD reject if supported.
Why CBOR:
- Compact binary format
- Self-describing (no schema required)
- Deterministic serialization possible
- Wide language support
- Used by COSE, WebAuthn, FIDO2
Extension Value Encoding
Extension values MUST be CBOR-encoded. The outer extensions map uses string keys and byte values, where each value is a CBOR-encoded structure.
Example:
// Extension definition
struct RateLimitExtension {
limit: u64,
window_secs: u64,
scope: u8,
}
// Encoding
let ext = RateLimitExtension { limit: 5, window_secs: 60, scope: 0 };
let cbor_bytes = cbor::encode(&ext)?;
// Storage in warrant
extensions.insert("tenuo.rate_limit", cbor_bytes);
Extension key namespaces:
| Key | Purpose | Status |
|---|---|---|
tenuo.session_id |
Session correlation | Implemented |
tenuo.agent_id |
Agent identification | Implemented |
tenuo.audit_id |
Audit trail correlation | Reserved |
tenuo.dedup_key |
Idempotency key | Reserved |
tenuo.rate_limit |
Rate limiting metadata | Reserved |
tenuo.trace_id |
Distributed tracing | Reserved |
User-defined keys: Use reverse domain notation (e.g., com.example.trace_id, org.acme.workflow_id).
Verifiers SHOULD reject warrants with unknown tenuo.* extensions to fail closed.
11. Warrant Stack (Transport)
For transport/storage of a warrant chain, use a WarrantStack:
type WarrantStack = Vec<SignedWarrant>; // CBOR Array of Warrants
- Order: Root -> Leaf (Root at index 0, Leaf at index N-1).
- Semantics: Used for “Disconnected Verification” where the verifier does not know the intermediate delegates.
11.1 Disambiguation (Array vs. Array)
Both SignedWarrant and WarrantStack are represented as CBOR Arrays.
SignedWarrant:Array(3)where element 0 isenvelope_version(Integer).WarrantStack:Array(N)where element 0 is aSignedWarrant(Array).
Parsers MUST inspect the first element to distinguish them:
- If index 0 is an Integer $\rightarrow$ It is a
SignedWarrant. - If index 0 is an Array $\rightarrow$ It is a
WarrantStack.
Verification (stack)
- Check limits:
stack.len()MUST NOT exceedMAX_CHAIN_DEPTH; total encoded size MUST NOT exceed 64 KB. - Iterate: Validate each link $i$ against $i-1$.
- $i=0$: Must be signed by a trusted root.
- $i>0$:
stack[i].issuer==stack[i-1].holder(Delegation Authority).stack[i].parent_hash== SHA256(stack[i-1].payload).stack[i].depth==stack[i-1].depth + 1.
- Result: The verified leaf is
stack[N-1].
12. Encoding and Representation
12.1 Base64 Encoding (Wire Transport)
When warrants are transmitted in text contexts (HTTP headers, JSON, logs), use:
- Encoding: Base64 URL-safe (RFC 4648 §5)
- Padding: No padding
// Encoding
let wire_bytes = cbor::serialize(&signed_warrant);
let text = base64::encode_config(&wire_bytes, base64::URL_SAFE_NO_PAD);
// Decoding
let wire_bytes = base64::decode_config(&text, base64::URL_SAFE_NO_PAD)?;
let signed_warrant: SignedWarrant = cbor::deserialize(&wire_bytes)?;
Why URL-safe base64:
- Safe in URLs, headers, filenames
- No
+or/characters that need escaping - Standard practice for tokens (JWT uses this)
12.2 Text Representation (PEM Armor)
For config files, logs, and human sharing, Tenuo supports three formats:
1. Explicit Stack (Production Format)
Use for transporting full chains in a single PEM block.
- Header:
-----BEGIN TENUO WARRANT CHAIN----- - Body: Base64 of CBOR(Array
) - Result:
WarrantStack
-----BEGIN TENUO WARRANT CHAIN-----
(Base64 of CBOR Array of SignedWarrants)
-----END TENUO WARRANT CHAIN-----
2. Implicit Stack (UNIX Format)
Use for concatenating individual warrant files (e.g. cat root.pem leaf.pem > chain.pem).
- Input: Multiple
-----BEGIN TENUO WARRANT-----blocks. - Result:
WarrantStack(constructed by parsing each block and appending to vector).
3. Single Warrant (Leaf Format)
Use for individual warrants (e.g. root keys, intermediate tickets).
- Header:
-----BEGIN TENUO WARRANT----- - Body: Base64 of CBOR(SignedWarrant)
- Result:
WarrantStack(containing 1 item).
-----BEGIN TENUO WARRANT-----
(Base64 of CBOR SignedWarrant)
-----END TENUO WARRANT-----
Key Formats:
- Public Keys: Standard SPKI PEM (
-----BEGIN PUBLIC KEY-----) - Private Keys: Standard PKCS#8 PEM (
-----BEGIN PRIVATE KEY-----)
12.3 PEM Transport Summary
Single Warrant
-----BEGIN TENUO WARRANT-----
<base64url>
-----END TENUO WARRANT-----
Chain (SSL-style concatenation)
Concatenated PEM blocks. Order: Root -> Leaf (parser handles either order; verification enforces strict hierarchy).
-----BEGIN TENUO WARRANT-----
<root base64url>
-----END TENUO WARRANT-----
-----BEGIN TENUO WARRANT-----
<child base64url>
-----END TENUO WARRANT-----
12.4 File Format
- Extension:
.tenuo - MIMEType:
application/vnd.tenuo+cbor - Magic bytes (binary):
0x54 0x45 0x4E 0x55 0x01(“TENU” + version)
Rules:
- File content is raw CBOR bytes (WarrantStack)
- Magic bytes appear at the start of the file, immediately followed by the CBOR bytes.
- Magic bytes are NOT used in PEM-armored text files (headers serve that purpose)
13. Size Limits
| Limit | Value | Rationale |
|---|---|---|
| Max warrant size | 64 KB | Prevents memory exhaustion |
| Max tools per warrant | 256 | Practical limit |
| Max constraints per tool | 64 | Practical limit |
| Max extension keys | 64 | Practical limit |
| Max extension value size | 8 KB | Prevents abuse |
| Max chain depth | 64 | Prevents DoS; typical chains are 3-5 levels |
| Max TTL | 90 days | Protocol ceiling; deployments can enforce stricter |
| Max tool name length | 256 bytes | Practical limit |
| Max constraint value length | 4 KB | Practical limit |
Verifiers must reject warrants exceeding these limits before full parsing.
WarrantStack size: The combined encoded size of a warrant plus its ancestors (see Section 11) MUST NOT exceed 256 KB.
14. Version Negotiation (Network Protocols)
Scope: This section applies only to network protocols (sidecar, gateway, MCP proxy). Standalone warrant verification uses the version fields embedded in the warrant itself - there is no negotiation.
For network protocols where client and server communicate over a session:
Client Server
| |
|--- Supported: [1, 2] -------->|
| |
|<-- Selected: 1 ---------------|
| |
|--- Warrant (v1 format) ------>|
Rules:
- Client sends list of supported protocol versions
- Server selects highest mutually supported version
- All subsequent messages use selected version
- If no overlap, connection fails
Note: This negotiates the protocol version (how messages are framed and exchanged), not the warrant version. Warrant versions are self-describing via envelope_version and version fields.
15. Proof-of-Possession (PoP) Wire Format
PoP prevents stolen warrants from being used without the holder’s private key.
PoP Challenge Structure
const POP_CONTEXT: &[u8] = b"tenuo-pop-v1";
PopChallenge = (warrant_id: String, tool: String, sorted_args: Vec<(String, Value)>, timestamp_window: i64)
Preimage = POP_CONTEXT || CBOR(PopChallenge)
Serialization:
- CBOR tuple (4 elements)
sorted_args: Arguments sorted lexicographically by keytimestamp_window: Floor division of Unix timestamp by 30 seconds, then multiply by 30- Domain separation: Preimage is
b"tenuo-pop-v1" || CBOR(challenge)to prevent cross-protocol reuse
Creating PoP:
const POP_CONTEXT: &[u8] = b"tenuo-pop-v1";
let now = Utc::now().timestamp();
let window_ts = (now / 30) * 30; // 30-second buckets
let challenge = (warrant.id.to_hex(), tool, sorted_args, window_ts);
let challenge_bytes = cbor_serialize(&challenge);
// Prepend domain separation context
let mut preimage = POP_CONTEXT.to_vec();
preimage.extend_from_slice(&challenge_bytes);
let signature = holder_keypair.sign(&preimage);
Verification:
// Try current and previous windows (handles clock skew)
for i in 0..4 { // max_windows = 4
let window_ts = ((now / 30) - i) * 30;
let challenge = (warrant.id.to_hex(), tool, sorted_args, window_ts);
let challenge_bytes = cbor_serialize(&challenge);
// Prepend domain separation context
let mut preimage = POP_CONTEXT.to_vec();
preimage.extend_from_slice(&challenge_bytes);
if holder_pubkey.verify(&preimage, &signature).is_ok() {
return Ok(());
}
}
Err("PoP failed or expired")
| Parameter | Value | Purpose |
|---|---|---|
| Context | tenuo-pop-v1 |
Domain separation |
| Window size | 30 seconds | Groups signatures into buckets |
| Max windows | 4 | ~2 minute total validity |
| Clock tolerance | ±30 seconds | Handles distributed clock skew |
16. Approval Wire Format (Multi-Sig)
Approvals are signed statements from external parties (humans, identity providers) authorizing an action.
Approval Structure
pub struct Approval {
request_hash: [u8; 32], // H(warrant_id || tool || sorted(args) || holder)
nonce: [u8; 16], // Random, replay protection
approver_key: PublicKey,
external_id: String, // e.g., "arn:aws:iam::123:user/admin"
provider: String, // e.g., "aws-iam"
approved_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
reason: Option<String>,
signature: Signature,
}
Signable Bytes
context || nonce || request_hash || external_id || approved_at || expires_at
Where:
context=b"tenuo-approval-v1"(domain separation prefix, same pattern as warrant signatures and PoP)approved_at,expires_at= little-endian i64 timestamps
Domain separation: This prefix prevents cross-protocol signature reuse, matching the pattern used for warrant signatures (tenuo-warrant-v1) and PoP (tenuo-pop-v1).
Serialization: CBOR map with string keys (via serde).
17. Signed Revocation List (SRL) Wire Format
The Control Plane signs revocation lists; authorizers verify before use.
SRL Payload
struct SrlPayload {
revoked_ids: Vec<String>, // Warrant IDs to revoke
version: u64, // Monotonic (anti-rollback)
issued_at: DateTime<Utc>,
issuer: PublicKey,
}
Signed Structure
pub struct SignedRevocationList {
payload: SrlPayload,
signature: Signature, // Over CBOR(payload)
}
Serialization: CBOR.
Anti-rollback: Authorizers MUST reject SRLs with version < current_version.
18. Revocation Request Wire Format
Authorized parties submit signed requests to revoke warrants.
Structure
pub struct RevocationRequest {
warrant_id: String,
reason: String,
requestor: PublicKey,
requested_at: DateTime<Utc>,
signature: Signature,
}
Signable Bytes
CBOR((warrant_id, reason, requestor, requested_at.timestamp()))
Authorization: | Requestor | Can Revoke | |———–|————| | Control Plane | Any warrant | | Issuer | Warrants they issued | | Holder | Their own warrant (surrender) |
Replay protection: Requests older than 5 minutes are rejected.
Summary
| Feature | Implementation | v1.0 Default |
|---|---|---|
| Envelope pattern | SignedWarrant { payload, signature } |
Yes |
| Envelope version | envelope_version: u8 |
1 |
| Payload version | version: u8 |
1 |
| Algorithm agility | PublicKey { algorithm, bytes } |
Ed25519 (1) |
| Timestamps | u64 |
Unix seconds |
| Tool-scoped constraints | BTreeMap<String, ConstraintSet> |
Yes |
| Standard constraints | Type IDs 1-127 | Yes |
| Experimental constraints | Type IDs 128-255 | Fail closed |
| Unknown constraints | Constraint::Unknown -> fails |
Yes |
| Extensions | BTreeMap<String, Vec<u8>> |
{} |
| Reserved namespace | tenuo:* only |
Rejected |
| Serialization | CBOR | Yes |
| Text encoding | Base64 URL-safe, no padding | Yes |
| Parent pointer | parent_hash = SHA256(payload_bytes) |
Yes |
| Transport | WarrantStack (Root -> Leaf) |
Yes |
| PoP challenge | CBOR tuple, 30s windows | Yes |
| Approval | CBOR, 16-byte nonce | Yes |
| SRL | CBOR, monotonic version | Yes |
| RevocationRequest | CBOR tuple | Yes |
References
Normative
- [RFC 4648] Josefsson, S., “The Base16, Base32, and Base64 Data Encodings”, October 2006. https://datatracker.ietf.org/doc/html/rfc4648
- [RFC 8032] Josefsson, S., Liusvaara, I., “Edwards-Curve Digital Signature Algorithm (EdDSA)”, January 2017. https://datatracker.ietf.org/doc/html/rfc8032
- [RFC 8949] Bormann, C., Hoffman, P., “Concise Binary Object Representation (CBOR)”, December 2020. https://datatracker.ietf.org/doc/html/rfc8949
Informative
- [Dennis1966] Dennis, J.B., Van Horn, E.C., “Programming Semantics for Multiprogrammed Computations”, Communications of the ACM, Vol. 9, No. 3, March 1966. https://doi.org/10.1145/365230.365252
- [Macaroons] Birgisson, A., Politz, J.G., Erlingsson, U., Taly, A., Vrable, M., Lentczner, M., “Macaroons: Cookies with Contextual Caveats for Decentralized Authorization in the Cloud”, NDSS 2014. https://research.google/pubs/pub41892/
Changelog
- 1.0 - Promoted to normative specification (2026-01-10)
- 0.1.1 - Added PoP, Approval, SRL, RevocationRequest wire formats
- 0.1 - Initial specification