use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::canonical::{self, CanonicalError};
use crate::provenance::{hmac_sha256_hex, sha256_hex};
pub const ENVELOPE_SCHEMA: &str = "cp1_signed_envelope";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedEnvelope {
pub cp: String,
pub schema: String,
pub payload: String,
pub sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hmac: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvelopeError {
BadSchema,
HashMismatch,
SignatureMissing,
SignatureInvalid,
NoKeyToVerify,
MalformedPayload,
NotCanonical,
SealBroken,
}
impl std::fmt::Display for EnvelopeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = match self {
EnvelopeError::BadSchema => "not a CP/1 signed envelope",
EnvelopeError::HashMismatch => "payload hash mismatch (tampered in transit)",
EnvelopeError::SignatureMissing => "fleet key configured but envelope is unsigned",
EnvelopeError::SignatureInvalid => "HMAC signature does not verify",
EnvelopeError::NoKeyToVerify => "envelope is signed but no fleet key is configured",
EnvelopeError::MalformedPayload => "payload is not valid JSON",
EnvelopeError::NotCanonical => "payload is not in CP/1 canonical form",
EnvelopeError::SealBroken => "document content_hash does not match its content",
};
f.write_str(message)
}
}
impl std::error::Error for EnvelopeError {}
impl SignedEnvelope {
pub fn seal(document: &Value, fleet_key: Option<&[u8]>) -> Result<Self, CanonicalError> {
let mut document = document.clone();
canonical::seal(&mut document)?;
let payload = canonical::to_canonical(&document)?;
let sha256 = sha256_hex(payload.as_bytes());
let hmac = fleet_key.map(|key| hmac_sha256_hex(key, sha256.as_bytes()));
Ok(Self {
cp: "cp1".to_string(),
schema: ENVELOPE_SCHEMA.to_string(),
payload,
sha256,
hmac,
})
}
pub fn open(&self, fleet_key: Option<&[u8]>) -> Result<Value, EnvelopeError> {
if self.cp != "cp1" || self.schema != ENVELOPE_SCHEMA {
return Err(EnvelopeError::BadSchema);
}
if sha256_hex(self.payload.as_bytes()) != self.sha256 {
return Err(EnvelopeError::HashMismatch);
}
match (fleet_key, &self.hmac) {
(Some(key), Some(mac)) => {
let expected = hmac_sha256_hex(key, self.sha256.as_bytes());
if !constant_time_eq(expected.as_bytes(), mac.as_bytes()) {
return Err(EnvelopeError::SignatureInvalid);
}
}
(Some(_), None) => return Err(EnvelopeError::SignatureMissing),
(None, Some(_)) => return Err(EnvelopeError::NoKeyToVerify),
(None, None) => {}
}
let document: Value =
serde_json::from_str(&self.payload).map_err(|_| EnvelopeError::MalformedPayload)?;
if canonical::to_canonical(&document).map_err(|_| EnvelopeError::NotCanonical)?
!= self.payload
{
return Err(EnvelopeError::NotCanonical);
}
if !canonical::verify_seal(&document).map_err(|_| EnvelopeError::NotCanonical)? {
return Err(EnvelopeError::SealBroken);
}
Ok(document)
}
pub fn to_line(&self) -> String {
let value = serde_json::to_value(self).expect("SignedEnvelope always serializes");
canonical::to_canonical(&value)
.expect("SignedEnvelope contains only strings")
}
pub fn from_line(line: &str) -> Result<Self, EnvelopeError> {
serde_json::from_str(line).map_err(|_| EnvelopeError::BadSchema)
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn document() -> Value {
json!({
"cp": "cp1",
"type": "Belief",
"id": "55555555-5555-4555-8555-555555555555",
"statement": "tests catch regressions",
"provenance": {
"authored_by": "adam",
"produced_at": "2026-01-01T00:00:00.000Z",
"origin": "reasoning",
"evidence": [],
"derived_from": []
}
})
}
#[test]
fn unsigned_round_trip_returns_the_same_document() {
let envelope = SignedEnvelope::seal(&document(), None).unwrap();
let opened = envelope.open(None).unwrap();
assert_eq!(opened["statement"], json!("tests catch regressions"));
assert!(canonical::verify_seal(&opened).unwrap());
}
#[test]
fn signed_round_trip_verifies_under_the_same_key() {
let key = b"fleet-secret";
let envelope = SignedEnvelope::seal(&document(), Some(key)).unwrap();
assert!(envelope.hmac.is_some());
assert!(envelope.open(Some(key)).is_ok());
}
#[test]
fn a_different_key_does_not_verify() {
let envelope = SignedEnvelope::seal(&document(), Some(b"fleet-secret")).unwrap();
assert_eq!(
envelope.open(Some(b"other-secret")),
Err(EnvelopeError::SignatureInvalid)
);
}
#[test]
fn an_unsigned_envelope_is_refused_when_a_key_is_required() {
let envelope = SignedEnvelope::seal(&document(), None).unwrap();
assert_eq!(
envelope.open(Some(b"fleet-secret")),
Err(EnvelopeError::SignatureMissing)
);
}
#[test]
fn a_signed_envelope_is_refused_when_no_key_is_configured() {
let envelope = SignedEnvelope::seal(&document(), Some(b"fleet-secret")).unwrap();
assert_eq!(envelope.open(None), Err(EnvelopeError::NoKeyToVerify));
}
#[test]
fn tampering_with_the_payload_is_caught_by_the_transport_hash() {
let mut envelope = SignedEnvelope::seal(&document(), None).unwrap();
envelope.payload = envelope.payload.replace("catch", "miss");
assert_eq!(envelope.open(None), Err(EnvelopeError::HashMismatch));
}
#[test]
fn a_consistently_rehashed_payload_is_still_caught_by_the_document_seal() {
let mut envelope = SignedEnvelope::seal(&document(), None).unwrap();
envelope.payload = envelope.payload.replace("catch", "misss");
envelope.sha256 = sha256_hex(envelope.payload.as_bytes());
assert_eq!(envelope.open(None), Err(EnvelopeError::SealBroken));
}
#[test]
fn a_non_canonical_payload_is_refused() {
let envelope = SignedEnvelope::seal(&document(), None).unwrap();
let reordered = format!(" {}", envelope.payload);
let tampered = SignedEnvelope {
sha256: sha256_hex(reordered.as_bytes()),
payload: reordered,
..envelope
};
assert_eq!(tampered.open(None), Err(EnvelopeError::NotCanonical));
}
#[test]
fn a_foreign_schema_is_refused_before_anything_else() {
let mut envelope = SignedEnvelope::seal(&document(), None).unwrap();
envelope.schema = "axiom_signed_export_v1".to_string();
assert_eq!(envelope.open(None), Err(EnvelopeError::BadSchema));
}
#[test]
fn line_protocol_round_trips_without_embedded_newlines() {
let envelope = SignedEnvelope::seal(&document(), Some(b"k")).unwrap();
let line = envelope.to_line();
assert!(!line.contains('\n'), "a line must not contain a newline");
assert_eq!(SignedEnvelope::from_line(&line).unwrap(), envelope);
}
#[test]
fn constant_time_eq_matches_ordinary_equality() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"ab"));
}
}