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}