Skip to main content

cortex_ledger/
signed_row.rs

1//! On-disk wire shape for a JSONL row that participates in the Ed25519
2//! signature chain (T-3.D.6, ADR 0010 §1-§2).
3//!
4//! A [`SignedRow`] is the canonical envelope written one-per-line to
5//! `events.jsonl`. It flattens the existing [`cortex_core::Event`] fields
6//! at the top level (so legacy tools that parse Event-shaped rows still
7//! see every field they expect) and adds **one** new field, `signature`,
8//! carrying the Ed25519 [`RowSignature`] over the row's canonical
9//! attestation preimage.
10//!
11//! ## Why a wrapper instead of a new field on `Event`
12//!
13//! `Event` lives in `cortex-core` and is part of the BUILD_SPEC §9.1
14//! wire-shape contract. Extending it would ripple through every consumer
15//! crate. The wrapper keeps the change local to `cortex-ledger`: we own
16//! the persistence layer, so we own the on-disk envelope.
17//!
18//! ## Backward compatibility on read
19//!
20//! `signature` is `Option<RowSignature>`; an old row that pre-dates this
21//! lane (no signature field on disk) deserializes with `signature: None`.
22//! That is **NOT** a silent pass — the audit verifier flags it as
23//! [`crate::audit::FailureReason::MissingSignature`] (per ADR 0010 §1
24//! "Single asymmetric trust domain": rows without a valid Ed25519
25//! signature do not verify; there is no symmetric-MAC fallback). A
26//! one-shot resign of any pre-3.D.6 fixture is required and documented
27//! in `scripts/resign-jsonl.sh`.
28//!
29//! ## Wire shape (illustrative — exact bytes are normative in code)
30//!
31//! ```json
32//! {
33//!   "id": "evt_…",
34//!   "schema_version": 1,
35//!   "observed_at": "2026-05-03T12:00:00.000000Z",
36//!   "recorded_at": "2026-05-03T12:00:00.100000Z",
37//!   "source": { "type": "user" },
38//!   "event_type": "cortex.event.user_message.v1",
39//!   "trace_id": null,
40//!   "session_id": "s-001",
41//!   "domain_tags": [],
42//!   "payload": { "text": "hello" },
43//!   "payload_hash": "…",
44//!   "prev_event_hash": null,
45//!   "event_hash": "…",
46//!   "signature": {
47//!     "schema_version": 1,
48//!     "key_id": "fp:abc…",
49//!     "signed_at": "2026-05-03T12:00:00.000000Z",
50//!     "bytes": "<base64 64-byte ed25519 signature>"
51//!   }
52//! }
53//! ```
54
55use chrono::{DateTime, Utc};
56use cortex_core::Event;
57use serde::{Deserialize, Serialize};
58
59/// Per-row Ed25519 signature persisted alongside the [`Event`] fields.
60///
61/// `bytes` is base64-encoded (URL-safe, no padding) so JSONL rows remain
62/// printable / grep-friendly. Verification reconstructs the canonical
63/// attestation preimage and checks `bytes` against the active operator
64/// public key (see [`crate::audit::verify_signed_chain`]).
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct RowSignature {
67    /// Schema version of the attestation preimage encoder used to produce
68    /// `bytes`. Mirrors
69    /// [`cortex_core::canonical::SCHEMA_VERSION_ATTESTATION`]. Verifiers
70    /// MUST fail closed on unknown versions (ADR 0010 §1b).
71    pub schema_version: u16,
72    /// Public-key fingerprint of the signing operator identity.
73    pub key_id: String,
74    /// Wall-clock timestamp at which the signature was produced. MUST
75    /// equal the `signed_at` field that went into the canonical preimage.
76    pub signed_at: DateTime<Utc>,
77    /// Base64 (URL-safe, no padding) of the 64-byte Ed25519 signature.
78    /// We avoid hex purely to keep the JSONL row narrower; a future
79    /// schema bump can choose a different encoding.
80    pub bytes: String,
81}
82
83/// On-disk envelope for one JSONL row.
84///
85/// `#[serde(flatten)]` on `event` keeps every existing top-level field
86/// where legacy tooling expects it; `signature` is a single new optional
87/// field. See module docs for the wire shape.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct SignedRow {
90    /// The semantic event payload (id, hashes, prev_event_hash, …). Its
91    /// fields are flattened to the top level by serde so the on-disk row
92    /// shape remains a superset of pre-3.D.6 rows.
93    #[serde(flatten)]
94    pub event: Event,
95    /// Optional row signature. `None` only on rows written via the legacy
96    /// unsigned `JsonlLog::append` path (kept for tests of unrelated
97    /// crate features). A `None` signature **always** fails verify with
98    /// [`crate::audit::FailureReason::MissingSignature`].
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub signature: Option<RowSignature>,
101}
102
103impl SignedRow {
104    /// Build a row from an event with no signature attached. Used by the
105    /// legacy unsigned append path; audit verify will flag the row.
106    #[must_use]
107    pub fn unsigned(event: Event) -> Self {
108        Self {
109            event,
110            signature: None,
111        }
112    }
113}
114
115// ---------- base64 helpers (dependency-free, URL-safe, no padding) ----------
116
117/// Encode bytes as URL-safe base64 (no padding). Local helper so the crate
118/// does not pull in the `base64` crate for one signature field.
119#[must_use]
120pub fn b64_encode(bytes: &[u8]) -> String {
121    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
122    let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3));
123    let mut i = 0;
124    while i + 3 <= bytes.len() {
125        let b0 = bytes[i];
126        let b1 = bytes[i + 1];
127        let b2 = bytes[i + 2];
128        out.push(ALPHABET[(b0 >> 2) as usize] as char);
129        out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
130        out.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char);
131        out.push(ALPHABET[(b2 & 0x3f) as usize] as char);
132        i += 3;
133    }
134    let rem = bytes.len() - i;
135    if rem == 1 {
136        let b0 = bytes[i];
137        out.push(ALPHABET[(b0 >> 2) as usize] as char);
138        out.push(ALPHABET[((b0 & 0x03) << 4) as usize] as char);
139    } else if rem == 2 {
140        let b0 = bytes[i];
141        let b1 = bytes[i + 1];
142        out.push(ALPHABET[(b0 >> 2) as usize] as char);
143        out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
144        out.push(ALPHABET[((b1 & 0x0f) << 2) as usize] as char);
145    }
146    out
147}
148
149/// Decode URL-safe base64 (no padding). Returns `None` on any invalid byte
150/// or truncated final group; the verifier treats that as
151/// [`crate::audit::FailureReason::BadSignature`].
152#[must_use]
153pub fn b64_decode(s: &str) -> Option<Vec<u8>> {
154    fn decode_char(c: u8) -> Option<u8> {
155        match c {
156            b'A'..=b'Z' => Some(c - b'A'),
157            b'a'..=b'z' => Some(c - b'a' + 26),
158            b'0'..=b'9' => Some(c - b'0' + 52),
159            b'-' => Some(62),
160            b'_' => Some(63),
161            _ => None,
162        }
163    }
164    let bytes = s.as_bytes();
165    if bytes.len() % 4 == 1 {
166        return None;
167    }
168    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
169    let mut i = 0;
170    while i + 4 <= bytes.len() {
171        let v0 = decode_char(bytes[i])?;
172        let v1 = decode_char(bytes[i + 1])?;
173        let v2 = decode_char(bytes[i + 2])?;
174        let v3 = decode_char(bytes[i + 3])?;
175        out.push((v0 << 2) | (v1 >> 4));
176        out.push((v1 << 4) | (v2 >> 2));
177        out.push((v2 << 6) | v3);
178        i += 4;
179    }
180    let rem = bytes.len() - i;
181    if rem == 2 {
182        let v0 = decode_char(bytes[i])?;
183        let v1 = decode_char(bytes[i + 1])?;
184        out.push((v0 << 2) | (v1 >> 4));
185    } else if rem == 3 {
186        let v0 = decode_char(bytes[i])?;
187        let v1 = decode_char(bytes[i + 1])?;
188        let v2 = decode_char(bytes[i + 2])?;
189        out.push((v0 << 2) | (v1 >> 4));
190        out.push((v1 << 4) | (v2 >> 2));
191    }
192    Some(out)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn b64_roundtrip_aligned() {
201        let bytes: Vec<u8> = (0..64).collect();
202        let s = b64_encode(&bytes);
203        let back = b64_decode(&s).unwrap();
204        assert_eq!(bytes, back);
205    }
206
207    #[test]
208    fn b64_roundtrip_one_byte_remainder() {
209        let bytes = vec![0xAB, 0xCD, 0xEF, 0x12];
210        let s = b64_encode(&bytes);
211        assert_eq!(s.len(), 6); // 4 bytes -> 6 base64 chars (no padding)
212        assert_eq!(b64_decode(&s).unwrap(), bytes);
213    }
214
215    #[test]
216    fn b64_roundtrip_two_byte_remainder() {
217        let bytes = vec![0xAB, 0xCD, 0xEF, 0x12, 0x34];
218        let s = b64_encode(&bytes);
219        assert_eq!(s.len(), 7);
220        assert_eq!(b64_decode(&s).unwrap(), bytes);
221    }
222
223    #[test]
224    fn b64_rejects_invalid_chars() {
225        assert!(b64_decode("AAAA!AAA").is_none());
226    }
227
228    #[test]
229    fn b64_rejects_invalid_length() {
230        // Length % 4 == 1 is impossible in canonical no-padding base64.
231        assert!(b64_decode("A").is_none());
232    }
233}