Skip to main content

harn_session_store/
signing.rs

1//! Ed25519 signing for session event chains.
2//!
3//! The store stamps each event with a sha256 `record_hash` linking
4//! back to its predecessor's hash. The `SessionSigner` finalises a
5//! session by emitting a `Receipt` event whose signature covers the
6//! entire chain. The same key also signs individual events when the
7//! caller opts in (`append_signed`) — useful for cross-tenant audit
8//! trails where every event needs independent verification.
9//!
10//! Key material is deterministically derived from a 32-byte seed
11//! supplied by the host. In production the seed comes from the
12//! configured secret store ([`harn_vm::provenance::load_or_generate_agent_signing_key`]
13//! is the long-form helper); for tests we accept a literal seed so
14//! the verifier can be exercised without a real KMS.
15
16use base64::Engine as _;
17use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
18use sha2::{Digest, Sha256};
19
20use super::event::{
21    canonical_event_bytes, canonical_json_bytes, EventId, EventSignature, SessionEventKind,
22    StoredEvent,
23};
24use super::store::{SessionMeta, VerifyFailure, VerifyReport};
25
26pub const ALGORITHM: &str = "ed25519";
27
28#[derive(Clone)]
29pub struct SessionSigner {
30    inner: std::sync::Arc<SigningKey>,
31    key_id: String,
32}
33
34impl SessionSigner {
35    /// Build a signer from a 32-byte seed. The verifying key id is
36    /// computed as `"sha256:" + hex(sha256(verifying_key_bytes))[..32]`
37    /// so the same seed always produces the same `signed_by.key_id`.
38    pub fn from_seed(seed: [u8; 32]) -> Self {
39        let key = SigningKey::from_bytes(&seed);
40        let key_id = key_id_for(&key.verifying_key());
41        Self {
42            inner: std::sync::Arc::new(key),
43            key_id,
44        }
45    }
46
47    pub fn key_id(&self) -> &str {
48        &self.key_id
49    }
50
51    pub fn verifying_key(&self) -> VerifyingKey {
52        self.inner.verifying_key()
53    }
54
55    pub fn sign_event(&self, event: &StoredEvent) -> EventSignature {
56        let bytes = canonical_event_bytes(event);
57        sign_bytes(&self.inner, &self.key_id, &bytes)
58    }
59
60    /// Sign a finalisation receipt over the full event-root hash. The
61    /// payload bytes are folded into the receipt event so a verifier
62    /// can recompute them from the stored chain alone.
63    pub fn sign_receipt(&self, receipt_root_hash: &str) -> EventSignature {
64        let material = receipt_signing_material(receipt_root_hash);
65        sign_bytes(&self.inner, &self.key_id, material.as_bytes())
66    }
67}
68
69fn sign_bytes(key: &SigningKey, key_id: &str, bytes: &[u8]) -> EventSignature {
70    let signature: Signature = key.sign(bytes);
71    EventSignature {
72        algorithm: ALGORITHM.to_string(),
73        key_id: key_id.to_string(),
74        signature: base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()),
75    }
76}
77
78fn receipt_signing_material(receipt_root_hash: &str) -> String {
79    format!("harn.session.receipt.v1\nroot={receipt_root_hash}\n")
80}
81
82pub fn key_id_for(verifying_key: &VerifyingKey) -> String {
83    let mut hasher = Sha256::new();
84    hasher.update(verifying_key.as_bytes());
85    let digest = hasher.finalize();
86    format!("sha256:{}", &hex::encode(digest)[..32])
87}
88
89/// Compute the canonical record hash for an event with `prev_hash`
90/// already populated. Result is `"sha256:<hex>"`.
91pub fn compute_record_hash(event: &StoredEvent) -> String {
92    let mut hasher = Sha256::new();
93    hasher.update(canonical_event_bytes(event));
94    finalize_sha256(hasher)
95}
96
97/// Wrap a finalised SHA-256 in the canonical `"sha256:<hex>"` label
98/// every chain primitive prints. Centralising the format keeps the
99/// algorithm tag in one place so a future cutover to a different hash
100/// doesn't fan out across the module.
101fn finalize_sha256(hasher: Sha256) -> String {
102    format!("sha256:{}", hex::encode(hasher.finalize()))
103}
104
105/// Verify a per-event signature.
106#[derive(Debug)]
107pub enum VerifyError {
108    NotSigned,
109    UnsupportedAlgorithm(String),
110    DecodeError(String),
111    InvalidShape(String),
112    BadSignature,
113    HashMismatch { stored: String, computed: String },
114}
115
116impl std::fmt::Display for VerifyError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::NotSigned => write!(f, "event is not signed"),
120            Self::UnsupportedAlgorithm(algorithm) => {
121                write!(f, "unsupported signature algorithm '{algorithm}'")
122            }
123            Self::DecodeError(message) => {
124                write!(f, "signature base64 decode failed: {message}")
125            }
126            Self::InvalidShape(message) => write!(f, "signature shape invalid: {message}"),
127            Self::BadSignature => write!(f, "signature did not verify against the key"),
128            Self::HashMismatch { stored, computed } => {
129                write!(
130                    f,
131                    "record_hash mismatch: stored '{stored}' vs computed '{computed}'"
132                )
133            }
134        }
135    }
136}
137
138impl std::error::Error for VerifyError {}
139
140pub fn verify_event(event: &StoredEvent, verifying_key: &VerifyingKey) -> Result<(), VerifyError> {
141    if event.is_redacted_projection() {
142        return Err(VerifyError::InvalidShape(format!(
143            "redacted projection cannot authenticate canonical source hash '{}'",
144            event.source_record_hash()
145        )));
146    }
147    let computed = compute_record_hash(event);
148    if computed != event.record_hash {
149        return Err(VerifyError::HashMismatch {
150            stored: event.record_hash.clone(),
151            computed,
152        });
153    }
154    let Some(signed_by) = event.signed_by.as_ref() else {
155        return Err(VerifyError::NotSigned);
156    };
157    verify_signature(signed_by, verifying_key, &canonical_event_bytes(event))
158}
159
160pub fn verify_receipt_root(
161    signed_by: &EventSignature,
162    verifying_key: &VerifyingKey,
163    receipt_root_hash: &str,
164) -> Result<(), VerifyError> {
165    let material = receipt_signing_material(receipt_root_hash);
166    verify_signature(signed_by, verifying_key, material.as_bytes())
167}
168
169fn verify_signature(
170    signed_by: &EventSignature,
171    verifying_key: &VerifyingKey,
172    bytes: &[u8],
173) -> Result<(), VerifyError> {
174    if signed_by.algorithm != ALGORITHM {
175        return Err(VerifyError::UnsupportedAlgorithm(
176            signed_by.algorithm.clone(),
177        ));
178    }
179    let signature_bytes = base64::engine::general_purpose::STANDARD
180        .decode(&signed_by.signature)
181        .map_err(|error| VerifyError::DecodeError(error.to_string()))?;
182    let signature = Signature::from_slice(&signature_bytes)
183        .map_err(|error| VerifyError::InvalidShape(error.to_string()))?;
184    verifying_key
185        .verify(bytes, &signature)
186        .map_err(|_| VerifyError::BadSignature)
187}
188
189/// Verify an entire stored event chain in one pass. For each event this
190/// checks the record-hash link, then verifies its signature (when
191/// present) against the appropriate key:
192///
193/// - A [`SessionEventKind::Receipt`] event's signature attests the
194///   *pre-receipt chain root* (see [`SessionSigner::sign_receipt`]), not
195///   the receipt event's own canonical bytes. It is verified with
196///   [`verify_receipt_root`] against the chain root recomputed over every
197///   event preceding it, using `receipt_verifier`.
198/// - Every other signed event is verified with [`verify_event`] against
199///   its own canonical bytes, using `event_verifier`.
200///
201/// When the relevant verifier is `None` a present signature is counted as
202/// signed-but-unverified, matching the historical behaviour of a store
203/// that persisted signatures but was reopened without the signing key.
204///
205/// Returns `(signed_event_count, failures)` where each failure is
206/// `(event_id, reason)`. Both backends map that into their `VerifyReport`.
207pub fn verify_event_chain(
208    events: &[StoredEvent],
209    event_verifier: Option<&VerifyingKey>,
210    receipt_verifier: Option<&VerifyingKey>,
211) -> (usize, Vec<(EventId, String)>) {
212    let (signed, failures) = verify_event_chain_detailed(events, event_verifier, receipt_verifier);
213    (
214        signed,
215        failures
216            .into_iter()
217            .map(|failure| (failure.event_id, failure.reason))
218            .collect(),
219    )
220}
221
222struct ChainFailure {
223    event_id: EventId,
224    reason: String,
225}
226
227fn verify_event_chain_detailed(
228    events: &[StoredEvent],
229    event_verifier: Option<&VerifyingKey>,
230    receipt_verifier: Option<&VerifyingKey>,
231) -> (usize, Vec<ChainFailure>) {
232    let mut signed = 0usize;
233    let mut failures = Vec::new();
234    let mut expected_prev_hash: Option<&str> = None;
235    for (index, event) in events.iter().enumerate() {
236        let expected_event_id = index as EventId + 1;
237        if event.event_id != expected_event_id {
238            failures.push(ChainFailure {
239                event_id: event.event_id,
240                reason: format!(
241                    "event_id sequence gap: expected {expected_event_id}, found {}",
242                    event.event_id
243                ),
244            });
245        }
246        if event.prev_hash.as_deref() != expected_prev_hash {
247            failures.push(ChainFailure {
248                event_id: event.event_id,
249                reason: "prev_hash chain break".to_string(),
250            });
251        }
252        if event.is_redacted_projection() {
253            failures.push(ChainFailure {
254                event_id: event.event_id,
255                reason: format!(
256                    "redacted projection cannot authenticate canonical source hash '{}'",
257                    event.source_record_hash()
258                ),
259            });
260            expected_prev_hash = Some(event.source_record_hash());
261            continue;
262        }
263        let recomputed = compute_record_hash(event);
264        if recomputed != event.record_hash {
265            failures.push(ChainFailure {
266                event_id: event.event_id,
267                reason: format!(
268                    "record_hash mismatch: stored '{stored}' vs computed '{recomputed}'",
269                    stored = event.record_hash
270                ),
271            });
272            expected_prev_hash = Some(event.record_hash.as_str());
273            continue;
274        }
275        expected_prev_hash = Some(event.record_hash.as_str());
276        let Some(signed_by) = event.signed_by.as_ref() else {
277            continue;
278        };
279        let is_receipt = matches!(event.kind, SessionEventKind::Receipt);
280        let verifier = if is_receipt {
281            receipt_verifier
282        } else {
283            event_verifier
284        };
285        let Some(verifier) = verifier else {
286            signed += 1;
287            continue;
288        };
289        let result = if is_receipt {
290            let pre_receipt_root = chain_root_hash(&events[..index]);
291            verify_receipt_root(signed_by, verifier, &pre_receipt_root)
292        } else {
293            verify_event(event, verifier)
294        };
295        match result {
296            Ok(()) => signed += 1,
297            Err(error) => failures.push(ChainFailure {
298                event_id: event.event_id,
299                reason: error.to_string(),
300            }),
301        }
302    }
303    (signed, failures)
304}
305
306/// Verify event bytes, sequence/linkage, signatures, and the persisted session
307/// counters/root as one contract shared by every backend.
308pub fn verify_session_chain(
309    meta: &SessionMeta,
310    events: &[StoredEvent],
311    event_verifier: Option<&VerifyingKey>,
312    receipt_verifier: Option<&VerifyingKey>,
313) -> VerifyReport {
314    let chain_root = chain_root_hash(events);
315    let (signed_event_count, mut failures) =
316        verify_event_chain_detailed(events, event_verifier, receipt_verifier);
317    let tail_id = events.last().map(|event| event.event_id).unwrap_or(0);
318    if meta.event_count != events.len() {
319        failures.push(ChainFailure {
320            event_id: tail_id,
321            reason: format!(
322                "session event_count mismatch: stored {}, found {}",
323                meta.event_count,
324                events.len()
325            ),
326        });
327    }
328    if meta.last_event_id != events.last().map(|event| event.event_id) {
329        failures.push(ChainFailure {
330            event_id: tail_id,
331            reason: "session last_event_id mismatch".to_string(),
332        });
333    }
334    let expected_root = if events.is_empty() {
335        None
336    } else {
337        Some(chain_root.as_str())
338    };
339    if meta.chain_root_hash.as_deref() != expected_root {
340        failures.push(ChainFailure {
341            event_id: tail_id,
342            reason: "session chain_root_hash mismatch".to_string(),
343        });
344    }
345    VerifyReport {
346        session_id: meta.id.clone(),
347        chain_root_hash: chain_root,
348        event_count: events.len(),
349        signed_event_count,
350        failures: failures
351            .into_iter()
352            .map(|failure| VerifyFailure {
353                event_id: failure.event_id,
354                reason: failure.reason,
355            })
356            .collect(),
357    }
358}
359
360/// Initial chain root before any events have been appended. The prefix
361/// is versioned so a future schema change doesn't silently re-validate
362/// against an old chain.
363pub fn chain_root_init() -> String {
364    let mut hasher = Sha256::new();
365    hasher.update(b"harn.session.chain.v2");
366    finalize_sha256(hasher)
367}
368
369/// Fold a single event's `record_hash` into the running chain root.
370/// Composing folds in sequence reproduces [`chain_root_hash`] without
371/// re-hashing the entire history on every append.
372pub fn chain_root_fold(prev_root: &str, record_hash: &str) -> String {
373    let mut hasher = Sha256::new();
374    hasher.update(prev_root.as_bytes());
375    hasher.update(b"\n");
376    hasher.update(record_hash.as_bytes());
377    finalize_sha256(hasher)
378}
379
380/// Build the canonical source-chain root for stored events or redacted
381/// retrieval projections. Projection markers retain the canonical source
382/// hash, so snapshot metadata and valid receipt signatures do not become
383/// false corruption reports merely because presentation bytes were scrubbed.
384/// The hot append path uses [`chain_root_fold`] directly.
385pub fn chain_root_hash(events: &[StoredEvent]) -> String {
386    events.iter().fold(chain_root_init(), |root, event| {
387        chain_root_fold(&root, event.source_record_hash())
388    })
389}
390
391/// Re-anchor a chain of events on a new owning session id. The
392/// `session_id` field is rewritten on each event and `prev_hash` +
393/// `record_hash` are recomputed sequentially, so the resulting chain
394/// is bytewise-verifiable as a standalone session. Used by
395/// [`crate::SessionStore::fork`] to give the child session
396/// a chain that `verify` can attest without the parent.
397pub fn re_anchor_events(events: &[StoredEvent], new_session_id: &str) -> Vec<StoredEvent> {
398    let mut rewritten = Vec::with_capacity(events.len());
399    let mut prev_hash: Option<String> = None;
400    for event in events {
401        let mut copied = event.clone();
402        copied.session_id = new_session_id.to_string();
403        copied.prev_hash = prev_hash.clone();
404        copied.record_hash = compute_record_hash(&copied);
405        // Per-event signatures are detached over the canonical bytes;
406        // since both session_id and prev_hash changed, the parent's
407        // signature no longer attests this event. Drop it — the
408        // session-close path will mint a fresh receipt covering the
409        // re-anchored chain.
410        copied.signed_by = None;
411        prev_hash = Some(copied.record_hash.clone());
412        rewritten.push(copied);
413    }
414    rewritten
415}
416
417/// Helper for receipt payloads built outside the signer.
418pub fn canonical_receipt_payload(
419    session_id: &str,
420    last_event_id: super::event::EventId,
421    chain_root: &str,
422) -> serde_json::Value {
423    serde_json::json!({
424        "schema": "harn.session.receipt.v1",
425        "session_id": session_id,
426        "last_event_id": last_event_id,
427        "chain_root": chain_root,
428    })
429}
430
431/// Canonicalise a [`serde_json::Value`] for use in tests that compare
432/// hashing inputs. Public surface kept tight; mostly used by the
433/// integration tests that round-trip events.
434pub fn canonical_value_hash(value: &serde_json::Value) -> String {
435    let mut hasher = Sha256::new();
436    hasher.update(canonical_json_bytes(value));
437    finalize_sha256(hasher)
438}