a1/wire.rs
1//! Portable wire formats for cross-service authorization transport.
2//!
3//! Enable with `features = ["wire"]`.
4//!
5//! # Why this module exists
6//!
7//! In a microservice architecture the service that *builds* a delegation chain
8//! and the service that *executes* an action under it are separate processes.
9//! `DyoloChain` and `AuthorizedAction` cannot cross that boundary directly:
10//! `DyoloChain` contains deserialized `ed25519-dalek` values, and
11//! `AuthorizedAction` is deliberately non-serializable (the sealed `_sealed` field
12//! enforces that authorization stays in-process).
13//!
14//! This module provides two cross-boundary types:
15//!
16//! - [`SignedChain`] — the full chain as a JSON/CBOR document. The *authorizing*
17//! service serializes it; the *executing* service deserializes it and calls
18//! [`DyoloChain::authorize`] again to re-verify.
19//! - [`VerifiedToken`] — a receipt authenticated with a shared HMAC key.
20//! The *authorizing* service verifies the chain and signs the receipt; the
21//! *executing* service checks the HMAC without re-running the chain. Suitable
22//! for high-throughput paths where re-verification is too slow.
23//!
24//! # Quick start
25//!
26//! ```rust,ignore
27//! use a1::wire::{SignedChain, VerifiedToken};
28//!
29//! // ── Authorizing service ───────────────────────────────────────────────────
30//! let signed = SignedChain::from_chain(&chain);
31//! let chain_json = serde_json::to_string(&signed)?;
32//!
33//! // Full re-verification on the executing service:
34//! let chain = SignedChain::from_json(&chain_json)?.into_chain()?;
35//! let action = chain.authorize(&agent_pk, &intent, &proof, &clock, &rev, &nonce)?;
36//!
37//! // ── For trust-delegated execution (shared MAC key out-of-band) ────────────
38//! let mac_key: [u8; 32] = /* from your secrets manager */;
39//! let token = VerifiedToken::sign(&action.receipt, &mac_key);
40//! let token_json = serde_json::to_string(&token)?;
41//!
42//! // Executing service just validates the MAC:
43//! let token: VerifiedToken = serde_json::from_str(&token_json)?;
44//! let receipt = token.verify(&mac_key)?;
45//! println!("Authorized depth={}", receipt.chain_depth);
46//! ```
47
48use ed25519_dalek::VerifyingKey;
49use serde::{Deserialize, Serialize};
50
51use crate::cert::DelegationCert;
52use crate::chain::{DyoloChain, VerificationReceipt};
53use crate::error::A1Error;
54
55// ── SignedChain ───────────────────────────────────────────────────────────────
56
57/// A portable, serializable representation of a [`DyoloChain`].
58///
59/// All `ed25519-dalek` types are encoded as hex strings so the wire format is
60/// language-agnostic. Any service that can deserialize JSON and call the
61/// a1 library (or a compatible implementation) can verify the chain.
62///
63/// The format is intentionally minimal: `principal_pk`, `principal_scope`, and
64/// `certs`. The chain fingerprint is recomputed on deserialization.
65///
66/// # Interoperability
67///
68/// Non-Rust services can verify a `SignedChain` by:
69/// 1. Deserializing the JSON into native types.
70/// 2. Re-running the Ed25519 batch verification over the cert chain.
71/// 3. Re-checking scope narrowing and temporal constraints.
72///
73/// A formal JSON Schema is published at
74/// <https://docs.rs/a1/latest/a1/wire/index.html>.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SignedChain {
77 /// Wire format version. Currently `1`.
78 pub version: u8,
79 /// Hex-encoded 32-byte Ed25519 verifying key of the root authority.
80 pub principal_pk: String,
81 /// Hex-encoded 32-byte Merkle root of the principal's intent set.
82 pub principal_scope: String,
83 /// Ordered delegation certificates from principal to terminal agent.
84 pub certs: Vec<DelegationCert>,
85}
86
87impl SignedChain {
88 /// Serialize a [`DyoloChain`] into a portable wire document.
89 pub fn from_chain(chain: &DyoloChain) -> Self {
90 Self {
91 version: 1,
92 principal_pk: hex::encode(chain.principal_pk.as_bytes()),
93 principal_scope: hex::encode(chain.principal_scope),
94 certs: chain.certs().to_vec(),
95 }
96 }
97
98 /// Deserialize a JSON wire document.
99 pub fn from_json(json: &str) -> Result<Self, A1Error> {
100 serde_json::from_str(json).map_err(|e| A1Error::WireFormatError(e.to_string()))
101 }
102
103 #[cfg(feature = "cbor")]
104 #[cfg_attr(docsrs, doc(cfg(feature = "cbor")))]
105 pub fn from_cbor(cbor: &[u8]) -> Result<Self, A1Error> {
106 ciborium::from_reader(cbor).map_err(|e| A1Error::WireFormatError(e.to_string()))
107 }
108
109 #[cfg(feature = "cbor")]
110 #[cfg_attr(docsrs, doc(cfg(feature = "cbor")))]
111 pub fn to_cbor(&self) -> Result<Vec<u8>, A1Error> {
112 let mut buf = Vec::new();
113 ciborium::into_writer(self, &mut buf)
114 .map_err(|e| A1Error::WireFormatError(e.to_string()))?;
115 Ok(buf)
116 }
117
118 /// Convert this wire document back into a live [`DyoloChain`].
119 ///
120 /// The receiver must specify the clock drift tolerance for the reconstructed chain.
121 /// This prevents malicious intermediaries from widening the temporal window.
122 pub fn into_chain_with_drift(self, drift_tolerance_secs: u64) -> Result<DyoloChain, A1Error> {
123 if self.version != 1 {
124 return Err(A1Error::UnsupportedVersion {
125 expected: 1,
126 got: self.version,
127 });
128 }
129
130 let pk_bytes: [u8; 32] = hex::decode(&self.principal_pk)
131 .map_err(|e| A1Error::WireFormatError(format!("principal_pk: {e}")))?
132 .try_into()
133 .map_err(|_| A1Error::WireFormatError("principal_pk must be 32 bytes".into()))?;
134
135 let scope_bytes: [u8; 32] = hex::decode(&self.principal_scope)
136 .map_err(|e| A1Error::WireFormatError(format!("principal_scope: {e}")))?
137 .try_into()
138 .map_err(|_| A1Error::WireFormatError("principal_scope must be 32 bytes".into()))?;
139
140 let pk = VerifyingKey::from_bytes(&pk_bytes)
141 .map_err(|e| A1Error::WireFormatError(format!("invalid principal_pk: {e}")))?;
142
143 let mut chain = DyoloChain::new(pk, scope_bytes).with_drift_tolerance(drift_tolerance_secs);
144
145 for cert in self.certs {
146 chain.push(cert);
147 }
148
149 Ok(chain)
150 }
151
152 #[deprecated(since = "2.0.0", note = "Use `into_chain_with_drift` instead.")]
153 pub fn into_chain(self) -> Result<DyoloChain, A1Error> {
154 self.into_chain_with_drift(15)
155 }
156
157 /// Serialize to a compact JSON string.
158 pub fn to_json(&self) -> Result<String, A1Error> {
159 serde_json::to_string(self).map_err(|e| A1Error::WireFormatError(e.to_string()))
160 }
161
162 /// Serialize to a pretty-printed JSON string (useful for audit logs).
163 pub fn to_json_pretty(&self) -> Result<String, A1Error> {
164 serde_json::to_string_pretty(self).map_err(|e| A1Error::WireFormatError(e.to_string()))
165 }
166}
167
168// ── VerifiedToken ─────────────────────────────────────────────────────────────
169
170/// A [`VerificationReceipt`] authenticated with a shared HMAC key.
171///
172/// Allows an executing service to accept an authorization decision from a
173/// trusted verifying service without re-running the full Ed25519 chain
174/// verification. The HMAC is computed with Blake3 in keyed mode over the
175/// canonical binary encoding of the receipt fields.
176///
177/// # Security requirements
178///
179/// - The `mac_key` must be a 32-byte secret shared exclusively between the
180/// verifying service and the executing service.
181/// - Rotate the key regularly (recommended: every 24 hours).
182/// - Transport tokens over a secure channel (TLS 1.3 minimum).
183/// - Set a short expiry on tokens — the receipt's `verified_at_unix` field
184/// lets executors enforce their own maximum age.
185///
186/// # Example
187///
188/// ```rust,ignore
189/// use a1::wire::VerifiedToken;
190///
191/// // Verifying service:
192/// let mac_key: [u8; 32] = /* from secrets manager */;
193/// let token = VerifiedToken::sign(&action.receipt, &mac_key);
194/// let json = serde_json::to_string(&token)?;
195/// // → send json over TLS to executing service
196///
197/// // Executing service:
198/// let token: VerifiedToken = serde_json::from_str(&json)?;
199/// let receipt = token.verify(&mac_key)?; // fails if tampered or wrong key
200/// // Use receipt.intent, receipt.chain_fingerprint for audit log
201/// ```
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct VerifiedToken {
204 /// The receipt to transport.
205 pub receipt: VerificationReceipt,
206 /// Hex-encoded 32-byte Blake3 keyed MAC over the canonical receipt bytes.
207 pub mac: String,
208}
209
210impl VerifiedToken {
211 /// Produce a `VerifiedToken` by signing `receipt` with the given 32-byte key.
212 pub fn sign(receipt: &VerificationReceipt, mac_key: &[u8; 32]) -> Self {
213 let mac = Self::compute_mac(receipt, mac_key);
214 Self {
215 receipt: receipt.clone(),
216 mac: hex::encode(mac),
217 }
218 }
219
220 /// Verify the MAC and return a reference to the receipt on success.
221 ///
222 /// Returns [`A1Error::InvalidSubScopeProof`] if the MAC is invalid
223 /// (tampered token, wrong key, or truncated hex). The specific error
224 /// variant is intentionally generic to avoid oracle attacks.
225 pub fn verify(&self, mac_key: &[u8; 32]) -> Result<&VerificationReceipt, A1Error> {
226 let mac_bytes: [u8; 32] = hex::decode(&self.mac)
227 .map_err(|_| A1Error::WireFormatError("invalid MAC hex".into()))?
228 .try_into()
229 .map_err(|_| A1Error::WireFormatError("MAC must be 32 bytes".into()))?;
230
231 let expected = Self::compute_mac(&self.receipt, mac_key);
232
233 // Constant-time comparison prevents timing side-channels.
234 use subtle::ConstantTimeEq;
235 if mac_bytes.ct_eq(&expected).unwrap_u8() == 0 {
236 return Err(A1Error::MacVerificationFailed);
237 }
238
239 Ok(&self.receipt)
240 }
241
242 fn compute_mac(receipt: &VerificationReceipt, key: &[u8; 32]) -> [u8; 32] {
243 let mut h = blake3::Hasher::new_keyed(key);
244 h.update(&receipt.canonical_bytes());
245 h.finalize().into()
246 }
247}
248
249// ── JSON Schema constant ──────────────────────────────────────────────────────
250
251/// JSON Schema for [`SignedChain`] (v1).
252///
253/// Generated via `schemars`.
254/// Embed this in your API documentation or OpenAPI spec to give non-Rust
255/// clients a machine-readable contract for the wire format.
256#[cfg(feature = "schema")]
257#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
258pub const SIGNED_CHAIN_SCHEMA_V1: &str = include_str!("../wire/schema.json");
259
260#[cfg(not(feature = "schema"))]
261pub const SIGNED_CHAIN_SCHEMA_V1: &str = "Enable the `schema` feature to include the JSON schema.";