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};
24
25pub const ALGORITHM: &str = "ed25519";
26
27#[derive(Clone)]
28pub struct SessionSigner {
29    inner: std::sync::Arc<SigningKey>,
30    key_id: String,
31}
32
33impl SessionSigner {
34    /// Build a signer from a 32-byte seed. The verifying key id is
35    /// computed as `"sha256:" + hex(sha256(verifying_key_bytes))[..32]`
36    /// so the same seed always produces the same `signed_by.key_id`.
37    pub fn from_seed(seed: [u8; 32]) -> Self {
38        let key = SigningKey::from_bytes(&seed);
39        let key_id = key_id_for(&key.verifying_key());
40        Self {
41            inner: std::sync::Arc::new(key),
42            key_id,
43        }
44    }
45
46    pub fn key_id(&self) -> &str {
47        &self.key_id
48    }
49
50    pub fn verifying_key(&self) -> VerifyingKey {
51        self.inner.verifying_key()
52    }
53
54    pub fn sign_event(&self, event: &StoredEvent) -> EventSignature {
55        let bytes = canonical_event_bytes(event);
56        sign_bytes(&self.inner, &self.key_id, &bytes)
57    }
58
59    /// Sign a finalisation receipt over the full event-root hash. The
60    /// payload bytes are folded into the receipt event so a verifier
61    /// can recompute them from the stored chain alone.
62    pub fn sign_receipt(&self, receipt_root_hash: &str) -> EventSignature {
63        let material = receipt_signing_material(receipt_root_hash);
64        sign_bytes(&self.inner, &self.key_id, material.as_bytes())
65    }
66}
67
68fn sign_bytes(key: &SigningKey, key_id: &str, bytes: &[u8]) -> EventSignature {
69    let signature: Signature = key.sign(bytes);
70    EventSignature {
71        algorithm: ALGORITHM.to_string(),
72        key_id: key_id.to_string(),
73        signature: base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()),
74    }
75}
76
77fn receipt_signing_material(receipt_root_hash: &str) -> String {
78    format!("harn.session.receipt.v1\nroot={receipt_root_hash}\n")
79}
80
81pub fn key_id_for(verifying_key: &VerifyingKey) -> String {
82    let mut hasher = Sha256::new();
83    hasher.update(verifying_key.as_bytes());
84    let digest = hasher.finalize();
85    format!("sha256:{}", &hex::encode(digest)[..32])
86}
87
88/// Compute the canonical record hash for an event with `prev_hash`
89/// already populated. Result is `"sha256:<hex>"`.
90pub fn compute_record_hash(event: &StoredEvent) -> String {
91    let mut hasher = Sha256::new();
92    hasher.update(canonical_event_bytes(event));
93    finalize_sha256(hasher)
94}
95
96/// Wrap a finalised SHA-256 in the canonical `"sha256:<hex>"` label
97/// every chain primitive prints. Centralising the format keeps the
98/// algorithm tag in one place so a future cutover to a different hash
99/// doesn't fan out across the module.
100fn finalize_sha256(hasher: Sha256) -> String {
101    format!("sha256:{}", hex::encode(hasher.finalize()))
102}
103
104/// Verify a per-event signature.
105#[derive(Debug)]
106pub enum VerifyError {
107    NotSigned,
108    UnsupportedAlgorithm(String),
109    DecodeError(String),
110    InvalidShape(String),
111    BadSignature,
112    HashMismatch { stored: String, computed: String },
113}
114
115impl std::fmt::Display for VerifyError {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            Self::NotSigned => write!(f, "event is not signed"),
119            Self::UnsupportedAlgorithm(algorithm) => {
120                write!(f, "unsupported signature algorithm '{algorithm}'")
121            }
122            Self::DecodeError(message) => {
123                write!(f, "signature base64 decode failed: {message}")
124            }
125            Self::InvalidShape(message) => write!(f, "signature shape invalid: {message}"),
126            Self::BadSignature => write!(f, "signature did not verify against the key"),
127            Self::HashMismatch { stored, computed } => {
128                write!(
129                    f,
130                    "record_hash mismatch: stored '{stored}' vs computed '{computed}'"
131                )
132            }
133        }
134    }
135}
136
137impl std::error::Error for VerifyError {}
138
139pub fn verify_event(event: &StoredEvent, verifying_key: &VerifyingKey) -> Result<(), VerifyError> {
140    let computed = compute_record_hash(event);
141    if computed != event.record_hash {
142        return Err(VerifyError::HashMismatch {
143            stored: event.record_hash.clone(),
144            computed,
145        });
146    }
147    let Some(signed_by) = event.signed_by.as_ref() else {
148        return Err(VerifyError::NotSigned);
149    };
150    verify_signature(signed_by, verifying_key, &canonical_event_bytes(event))
151}
152
153pub fn verify_receipt_root(
154    signed_by: &EventSignature,
155    verifying_key: &VerifyingKey,
156    receipt_root_hash: &str,
157) -> Result<(), VerifyError> {
158    let material = receipt_signing_material(receipt_root_hash);
159    verify_signature(signed_by, verifying_key, material.as_bytes())
160}
161
162fn verify_signature(
163    signed_by: &EventSignature,
164    verifying_key: &VerifyingKey,
165    bytes: &[u8],
166) -> Result<(), VerifyError> {
167    if signed_by.algorithm != ALGORITHM {
168        return Err(VerifyError::UnsupportedAlgorithm(
169            signed_by.algorithm.clone(),
170        ));
171    }
172    let signature_bytes = base64::engine::general_purpose::STANDARD
173        .decode(&signed_by.signature)
174        .map_err(|error| VerifyError::DecodeError(error.to_string()))?;
175    let signature = Signature::from_slice(&signature_bytes)
176        .map_err(|error| VerifyError::InvalidShape(error.to_string()))?;
177    verifying_key
178        .verify(bytes, &signature)
179        .map_err(|_| VerifyError::BadSignature)
180}
181
182/// Verify an entire stored event chain in one pass. For each event this
183/// checks the record-hash link, then verifies its signature (when
184/// present) against the appropriate key:
185///
186/// - A [`SessionEventKind::Receipt`] event's signature attests the
187///   *pre-receipt chain root* (see [`SessionSigner::sign_receipt`]), not
188///   the receipt event's own canonical bytes. It is verified with
189///   [`verify_receipt_root`] against the chain root recomputed over every
190///   event preceding it, using `receipt_verifier`.
191/// - Every other signed event is verified with [`verify_event`] against
192///   its own canonical bytes, using `event_verifier`.
193///
194/// When the relevant verifier is `None` a present signature is counted as
195/// signed-but-unverified, matching the historical behaviour of a store
196/// that persisted signatures but was reopened without the signing key.
197///
198/// Returns `(signed_event_count, failures)` where each failure is
199/// `(event_id, reason)`. Both backends map that into their `VerifyReport`.
200pub fn verify_event_chain(
201    events: &[StoredEvent],
202    event_verifier: Option<&VerifyingKey>,
203    receipt_verifier: Option<&VerifyingKey>,
204) -> (usize, Vec<(EventId, String)>) {
205    let mut signed = 0usize;
206    let mut failures: Vec<(EventId, String)> = Vec::new();
207    for (index, event) in events.iter().enumerate() {
208        let recomputed = compute_record_hash(event);
209        if recomputed != event.record_hash {
210            failures.push((
211                event.event_id,
212                format!(
213                    "record_hash mismatch: stored '{stored}' vs computed '{recomputed}'",
214                    stored = event.record_hash
215                ),
216            ));
217            continue;
218        }
219        let Some(signed_by) = event.signed_by.as_ref() else {
220            continue;
221        };
222        let is_receipt = matches!(event.kind, SessionEventKind::Receipt);
223        let verifier = if is_receipt {
224            receipt_verifier
225        } else {
226            event_verifier
227        };
228        let Some(verifier) = verifier else {
229            signed += 1;
230            continue;
231        };
232        let result = if is_receipt {
233            let pre_receipt_root = chain_root_hash(&events[..index]);
234            verify_receipt_root(signed_by, verifier, &pre_receipt_root)
235        } else {
236            verify_event(event, verifier)
237        };
238        match result {
239            Ok(()) => signed += 1,
240            Err(error) => failures.push((event.event_id, error.to_string())),
241        }
242    }
243    (signed, failures)
244}
245
246/// Initial chain root before any events have been appended. The prefix
247/// is versioned so a future schema change doesn't silently re-validate
248/// against an old chain.
249pub fn chain_root_init() -> String {
250    let mut hasher = Sha256::new();
251    hasher.update(b"harn.session.chain.v2");
252    finalize_sha256(hasher)
253}
254
255/// Fold a single event's `record_hash` into the running chain root.
256/// Composing folds in sequence reproduces [`chain_root_hash`] without
257/// re-hashing the entire history on every append.
258pub fn chain_root_fold(prev_root: &str, record_hash: &str) -> String {
259    let mut hasher = Sha256::new();
260    hasher.update(prev_root.as_bytes());
261    hasher.update(b"\n");
262    hasher.update(record_hash.as_bytes());
263    finalize_sha256(hasher)
264}
265
266/// Build the chain root hash for a list of stored events by replaying
267/// the fold from genesis. Used by `verify` and by snapshot/replay; the
268/// hot append path uses [`chain_root_fold`] directly.
269pub fn chain_root_hash(events: &[StoredEvent]) -> String {
270    events.iter().fold(chain_root_init(), |root, event| {
271        chain_root_fold(&root, &event.record_hash)
272    })
273}
274
275/// Re-anchor a chain of events on a new owning session id. The
276/// `session_id` field is rewritten on each event and `prev_hash` +
277/// `record_hash` are recomputed sequentially, so the resulting chain
278/// is bytewise-verifiable as a standalone session. Used by
279/// [`crate::SessionStore::fork`] to give the child session
280/// a chain that `verify` can attest without the parent.
281pub fn re_anchor_events(events: &[StoredEvent], new_session_id: &str) -> Vec<StoredEvent> {
282    let mut rewritten = Vec::with_capacity(events.len());
283    let mut prev_hash: Option<String> = None;
284    for event in events {
285        let mut copied = event.clone();
286        copied.session_id = new_session_id.to_string();
287        copied.prev_hash = prev_hash.clone();
288        copied.record_hash = compute_record_hash(&copied);
289        // Per-event signatures are detached over the canonical bytes;
290        // since both session_id and prev_hash changed, the parent's
291        // signature no longer attests this event. Drop it — the
292        // session-close path will mint a fresh receipt covering the
293        // re-anchored chain.
294        copied.signed_by = None;
295        prev_hash = Some(copied.record_hash.clone());
296        rewritten.push(copied);
297    }
298    rewritten
299}
300
301/// Helper for receipt payloads built outside the signer.
302pub fn canonical_receipt_payload(
303    session_id: &str,
304    last_event_id: super::event::EventId,
305    chain_root: &str,
306) -> serde_json::Value {
307    serde_json::json!({
308        "schema": "harn.session.receipt.v1",
309        "session_id": session_id,
310        "last_event_id": last_event_id,
311        "chain_root": chain_root,
312    })
313}
314
315/// Canonicalise a [`serde_json::Value`] for use in tests that compare
316/// hashing inputs. Public surface kept tight; mostly used by the
317/// integration tests that round-trip events.
318pub fn canonical_value_hash(value: &serde_json::Value) -> String {
319    let mut hasher = Sha256::new();
320    hasher.update(canonical_json_bytes(value));
321    finalize_sha256(hasher)
322}