Skip to main content

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.";