sbo3l-core 1.2.2

SBO3L core: protocol types, request hashing, error model, signed receipts.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
//! Production-shaped **mock** KMS signer.
//!
//! `MockKmsSigner` looks and behaves like a key-managed signing service: it
//! has stable `key_id`s, explicit `key_version`s, public-key metadata, and
//! supports rotation while keeping historical keys verifiable. It is
//! deliberately **mock** — keys are derived deterministically from a local
//! root seed (no HSM, no TEE, no remote KMS, no network). The only thing
//! it shares with a real KMS is the *shape* of the API and the lifecycle
//! semantics (rotation, version-by-version verification).
//!
//! Truthfulness rules (do not violate):
//! - Every public artefact must say "mock" explicitly.
//! - The struct's seeds, public keys, and key_ids are derived from a local
//!   value; they are **not** secrets in the production sense and must not
//!   be presented as such.
//! - This is not a stepping stone "you flip one bit and it's production".
//!   A real KMS implementation would replace the `derive_signing_key`
//!   call with a call to the KMS API, AND change the trust model in many
//!   other places (key custody, audit, attestation, recovery, etc).
//!
//! What `MockKmsSigner` does provide:
//! - A versioned keyring under a stable role name (e.g. `audit-mock`).
//! - Each version has a `key_id` like `audit-mock-v1`, `audit-mock-v2`, …
//!   recorded verbatim in `EmbeddedSignature.key_id` on the receipts/
//!   audit events / decision tokens it signs.
//! - `rotate()` advances the current version. Past versions stay available
//!   for verifying earlier receipts.
//! - The signing path goes through the same `SignerBackend` trait used by
//!   `DevSigner`, so swapping backends is a one-line change at the call
//!   site.

use chrono::{DateTime, Utc};
use ed25519_dalek::SigningKey;
use sha2::Digest;

use crate::signer::{verify_hex, DevSigner, SignerBackend, VerifyError};

/// Metadata describing one version of a mock-KMS key. Exposed via
/// `MockKmsSigner::versions()` so callers (CLI, tests, docs) can enumerate
/// the keyring without reaching into private state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MockKmsKeyMeta {
    pub role: String,
    pub version: u32,
    pub key_id: String,
    pub public_hex: String,
    /// When this version was added to the keyring. Deterministic from the
    /// caller-supplied clock so tests and demos stay reproducible.
    pub created_at: DateTime<Utc>,
    /// Always `true` — this struct only represents mock keys. Surfaced as
    /// a field so JSON/CLI output cannot accidentally drop the disclosure.
    pub mock: bool,
}

/// Mock KMS-shaped Ed25519 signer with rotation.
///
/// Construct with a stable `role` (e.g. `"audit-mock"` or
/// `"decision-mock"`) and a 32-byte `root_seed`. Rotations are pure
/// in-memory state — persistence (so `sbo3l key rotate --mock` can
/// outlive a CLI invocation) is a follow-up wired in `sbo3l-storage`.
#[derive(Debug, Clone)]
pub struct MockKmsSigner {
    role: String,
    root_seed: [u8; 32],
    /// Genesis timestamp for v1; subsequent versions are spaced by one
    /// nanosecond so `created_at` is monotonically increasing without
    /// pulling in a real clock.
    genesis: DateTime<Utc>,
    versions: Vec<MockKmsKeyMeta>,
    current_index: usize,
}

impl MockKmsSigner {
    /// Build a fresh keyring with v1 derived from `(root_seed, role, 1)`.
    /// `genesis` is the deterministic "created_at" anchor for v1.
    pub fn new(role: impl Into<String>, root_seed: [u8; 32], genesis: DateTime<Utc>) -> Self {
        let role = role.into();
        let v1 = Self::build_meta(&role, 1, &root_seed, genesis);
        Self {
            role,
            root_seed,
            genesis,
            versions: vec![v1],
            current_index: 0,
        }
    }

    /// Reconstruct a keyring with `current_version` already advanced. Used
    /// by callers that persist the rotation state externally and want to
    /// rebuild the in-memory keyring on startup.
    pub fn from_versions(
        role: impl Into<String>,
        root_seed: [u8; 32],
        genesis: DateTime<Utc>,
        current_version: u32,
    ) -> Self {
        assert!(current_version >= 1, "version numbering starts at 1");
        let role = role.into();
        let versions: Vec<_> = (1..=current_version)
            .map(|v| Self::build_meta(&role, v, &root_seed, genesis))
            .collect();
        let current_index = (current_version as usize) - 1;
        Self {
            role,
            root_seed,
            genesis,
            versions,
            current_index,
        }
    }

    fn build_meta(
        role: &str,
        version: u32,
        root_seed: &[u8; 32],
        genesis: DateTime<Utc>,
    ) -> MockKmsKeyMeta {
        let signing_key = derive_signing_key(role, version, root_seed);
        let public_hex = hex::encode(signing_key.verifying_key().to_bytes());
        let key_id = format!("{role}-v{version}");
        // Space versions by one nanosecond so the timeline is strictly
        // monotonic but still derives only from the caller's `genesis`.
        let created_at =
            genesis + chrono::Duration::nanoseconds(((version as i64).saturating_sub(1)).max(0));
        MockKmsKeyMeta {
            role: role.to_string(),
            version,
            key_id,
            public_hex,
            created_at,
            mock: true,
        }
    }

    pub fn role(&self) -> &str {
        &self.role
    }

    pub fn current_version(&self) -> u32 {
        self.versions[self.current_index].version
    }

    /// Metadata for the current (active for new signatures) version.
    pub fn current(&self) -> &MockKmsKeyMeta {
        &self.versions[self.current_index]
    }

    /// All keyring entries from v1 through the current version, in order.
    pub fn versions(&self) -> &[MockKmsKeyMeta] {
        &self.versions
    }

    /// Look up a keyring entry by its full `key_id` (e.g. `audit-mock-v2`).
    /// Returns `None` if the id is unknown.
    pub fn key_by_id(&self, key_id: &str) -> Option<&MockKmsKeyMeta> {
        self.versions.iter().find(|m| m.key_id == key_id)
    }

    /// Look up a keyring entry by `version` (1-indexed).
    pub fn key_by_version(&self, version: u32) -> Option<&MockKmsKeyMeta> {
        self.versions.iter().find(|m| m.version == version)
    }

    /// Add a new version. The new version becomes the current signing
    /// key; previous versions remain available for verification of
    /// earlier signatures via `verify(...)`.
    pub fn rotate(&mut self) -> &MockKmsKeyMeta {
        let next_version = self.current_version() + 1;
        let meta = Self::build_meta(&self.role, next_version, &self.root_seed, self.genesis);
        self.versions.push(meta);
        self.current_index = self.versions.len() - 1;
        self.current()
    }

    fn current_signing_key(&self) -> SigningKey {
        derive_signing_key(&self.role, self.current_version(), &self.root_seed)
    }

    /// Verify a signature claimed to be produced under `key_id`. Resolves
    /// the keyring entry, then runs the standard Ed25519 verify.
    pub fn verify(
        &self,
        key_id: &str,
        message: &[u8],
        signature_hex: &str,
    ) -> Result<(), VerifyError> {
        let meta = self.key_by_id(key_id).ok_or(VerifyError::BadPublicKey)?;
        verify_hex(&meta.public_hex, message, signature_hex)
    }

    /// Convenience: derive a `DevSigner` for the *current* version. Useful
    /// when a caller wants to interoperate with code that holds onto a
    /// `DevSigner` directly (e.g. existing `audit_append` paths) without
    /// committing to the keyring lifecycle.
    pub fn current_as_dev_signer(&self) -> DevSigner {
        DevSigner::from_seed(
            self.current().key_id.clone(),
            seed_for(&self.role, self.current_version(), &self.root_seed),
        )
    }
}

impl SignerBackend for MockKmsSigner {
    fn current_key_id(&self) -> &str {
        &self.current().key_id
    }
    fn sign_hex(&self, message: &[u8]) -> String {
        use ed25519_dalek::Signer as _;
        let sk = self.current_signing_key();
        let sig = sk.sign(message);
        hex::encode(sig.to_bytes())
    }
    fn current_public_hex(&self) -> String {
        self.current().public_hex.clone()
    }
}

/// Build the `(key_id, public_hex)` pair that
/// `MockKmsSigner::new` / `rotate` would produce for `(role, version,
/// root_seed)`. Useful for callers that store keyring metadata
/// externally (e.g. SQLite via `sbo3l-storage::mock_kms_store`) and
/// need to derive the next version's public material without holding
/// a `MockKmsSigner` instance.
pub fn derive_key_metadata(role: &str, version: u32, root_seed: &[u8; 32]) -> (String, String) {
    let signing_key = derive_signing_key(role, version, root_seed);
    let public_hex = hex::encode(signing_key.verifying_key().to_bytes());
    let key_id = format!("{role}-v{version}");
    (key_id, public_hex)
}

/// Deterministic per-version seed. **Not** a production KDF — a real KMS
/// would never derive private keys from a public-ish role+version tuple.
/// This is sufficient for reproducible local testing where the same
/// (root_seed, role, version) triple yields the same Ed25519 keypair.
fn seed_for(role: &str, version: u32, root_seed: &[u8; 32]) -> [u8; 32] {
    let mut hasher = sha2::Sha256::new();
    hasher.update(b"sbo3l.mock_kms.v1");
    hasher.update(root_seed);
    hasher.update((role.len() as u32).to_be_bytes());
    hasher.update(role.as_bytes());
    hasher.update(version.to_be_bytes());
    let digest = hasher.finalize();
    let mut out = [0u8; 32];
    out.copy_from_slice(&digest);
    out
}

fn derive_signing_key(role: &str, version: u32, root_seed: &[u8; 32]) -> SigningKey {
    SigningKey::from_bytes(&seed_for(role, version, root_seed))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ts(s: &str) -> DateTime<Utc> {
        chrono::DateTime::parse_from_rfc3339(s).unwrap().into()
    }

    #[test]
    fn fresh_signer_starts_at_v1() {
        let s = MockKmsSigner::new("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"));
        assert_eq!(s.current_version(), 1);
        assert_eq!(s.current_key_id(), "audit-mock-v1");
        assert_eq!(s.versions().len(), 1);
        assert!(s.current().mock, "metadata must surface mock = true");
    }

    #[test]
    fn key_metadata_is_deterministic() {
        // Same (role, root_seed, genesis) → same keyring exactly. This is
        // the property tests, fixtures and demos rely on.
        let a = MockKmsSigner::new("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"));
        let b = MockKmsSigner::new("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"));
        assert_eq!(a.versions(), b.versions());
    }

    #[test]
    fn different_roots_yield_different_keys() {
        let a = MockKmsSigner::new("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"));
        let b = MockKmsSigner::new("audit-mock", [99u8; 32], ts("2026-04-28T00:00:00Z"));
        assert_ne!(a.current().public_hex, b.current().public_hex);
    }

    #[test]
    fn signer_backend_round_trip() {
        // Sign through the trait; verify through the keyring's own verify
        // (which looks up the public key by key_id).
        let s = MockKmsSigner::new("decision-mock", [7u8; 32], ts("2026-04-28T00:00:00Z"));
        let msg = b"some canonical payload";
        let sig = SignerBackend::sign_hex(&s, msg);
        s.verify(s.current_key_id(), msg, &sig)
            .expect("self-verify must succeed");
    }

    #[test]
    fn signer_backend_reports_consistent_metadata() {
        let s = MockKmsSigner::new("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"));
        // current_key_id and current_public_hex must agree with the
        // keyring's metadata, otherwise verifiers and signers would
        // disagree about what just got signed.
        assert_eq!(s.current_key_id(), s.current().key_id);
        assert_eq!(s.current_public_hex(), s.current().public_hex);
    }

    #[test]
    fn rotate_advances_current_version() {
        let mut s = MockKmsSigner::new("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"));
        let before = s.current().clone();
        let after = s.rotate().clone();
        assert_eq!(after.version, 2);
        assert_eq!(after.key_id, "audit-mock-v2");
        assert_ne!(
            after.public_hex, before.public_hex,
            "rotation must change public key"
        );
        assert_eq!(s.current_version(), 2);
        assert_eq!(s.versions().len(), 2);
    }

    #[test]
    fn old_signature_still_verifies_after_rotation() {
        // The whole point of carrying historical keys: an audit event
        // signed before a rotation must remain verifiable after.
        let mut s = MockKmsSigner::new("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"));
        let v1_key_id = s.current_key_id().to_string();
        let msg = b"pre-rotation message";
        let sig = SignerBackend::sign_hex(&s, msg);

        s.rotate();
        assert_eq!(s.current_version(), 2);

        // v1 signature still verifies under the v1 keyring entry.
        s.verify(&v1_key_id, msg, &sig)
            .expect("v1 signature must still verify under v1 pubkey after rotation");
        // And the v2 pubkey must NOT verify the v1 signature.
        let v2_key_id = s.current_key_id().to_string();
        let res = s.verify(&v2_key_id, msg, &sig);
        assert!(matches!(res, Err(VerifyError::Invalid)));
    }

    #[test]
    fn wrong_key_id_returns_bad_public_key() {
        let s = MockKmsSigner::new("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"));
        let msg = b"x";
        let sig = SignerBackend::sign_hex(&s, msg);
        let res = s.verify("audit-mock-v999", msg, &sig);
        assert!(matches!(res, Err(VerifyError::BadPublicKey)));
    }

    #[test]
    fn from_versions_reconstructs_same_keyring_after_rotations() {
        // Persistence story (when the rotation count is stored elsewhere
        // and we rebuild the in-memory keyring at startup): reconstructing
        // with `current_version=N` produces the same N entries as
        // `new(...)` followed by `N-1` rotations.
        let mut grown = MockKmsSigner::new("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"));
        for _ in 0..3 {
            grown.rotate();
        }
        let restored =
            MockKmsSigner::from_versions("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"), 4);
        assert_eq!(grown.versions(), restored.versions());
        assert_eq!(grown.current_version(), restored.current_version());
    }

    #[test]
    fn current_as_dev_signer_produces_compatible_signatures() {
        // The compat shim lets MockKmsSigner interoperate with code that
        // still takes `&DevSigner`. The DevSigner it returns must produce
        // signatures verifiable under the keyring's current public key.
        let s = MockKmsSigner::new("audit-mock", [42u8; 32], ts("2026-04-28T00:00:00Z"));
        let dev = s.current_as_dev_signer();
        assert_eq!(dev.current_key_id(), s.current_key_id());
        let msg = b"compat";
        let sig = dev.sign_hex(msg);
        s.verify(s.current_key_id(), msg, &sig)
            .expect("DevSigner-produced signature must verify under MockKms keyring");
    }

    // -- Cross-cutting integration tests ------------------------------------
    //
    // The point of `SignerBackend` is that the three SBO3L signing
    // surfaces (receipts, audit events, decision tokens) work with any
    // backend without code changes. Below: sign through MockKmsSigner,
    // then verify using the same code paths the daemon uses today.

    use crate::audit::{AuditEvent, SignedAuditEvent, ZERO_HASH};
    use crate::decision_token::{DecisionPayload, TxTemplate};
    use crate::receipt::{Decision, UnsignedReceipt};

    fn unsigned_receipt(audit_event_id: &str) -> UnsignedReceipt {
        UnsignedReceipt {
            agent_id: "research-agent-01".to_string(),
            decision: Decision::Allow,
            deny_code: None,
            request_hash: "1111111111111111111111111111111111111111111111111111111111111111"
                .to_string(),
            policy_hash: "2222222222222222222222222222222222222222222222222222222222222222"
                .to_string(),
            policy_version: Some(1),
            audit_event_id: audit_event_id.to_string(),
            execution_ref: None,
            issued_at: ts("2026-04-27T12:00:01.500Z"),
            expires_at: None,
        }
    }

    fn audit_event(seq: u64) -> AuditEvent {
        AuditEvent {
            version: 1,
            seq,
            id: format!("evt-mock-kms-{seq:03}"),
            ts: ts("2026-04-27T12:00:01Z"),
            event_type: "policy_decided".to_string(),
            actor: "policy_engine".to_string(),
            subject_id: format!("pr-mock-{seq:03}"),
            payload_hash: ZERO_HASH.to_string(),
            metadata: serde_json::Map::new(),
            policy_version: Some(1),
            policy_hash: None,
            attestation_ref: None,
            prev_event_hash: ZERO_HASH.to_string(),
        }
    }

    fn decision_payload(request_hash: &str) -> DecisionPayload {
        DecisionPayload {
            version: 1,
            request_hash: request_hash.to_string(),
            decision: Decision::Allow,
            deny_code: None,
            policy_version: 1,
            policy_hash: "2222222222222222222222222222222222222222222222222222222222222222"
                .to_string(),
            tx_template: TxTemplate {
                chain_id: 8453,
                to: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913".to_string(),
                value: "0".to_string(),
                data: "0x".to_string(),
                gas_limit: 100_000,
                max_fee_per_gas: None,
                max_priority_fee_per_gas: None,
                nonce_hint: None,
            },
            key_id: "agent-research-01-key".to_string(),
            decision_id: "dec-mock-kms-001".to_string(),
            issued_at: ts("2026-04-27T12:00:00Z"),
            expires_at: ts("2026-04-27T12:05:00Z"),
            attestation_ref: None,
        }
    }

    #[test]
    fn receipt_round_trip_through_mock_kms() {
        // The receipt's signature.key_id MUST be the keyring's current
        // key_id (so a verifier can resolve which version produced it),
        // and verifying with the matching public key MUST succeed.
        let s = MockKmsSigner::new("decision-mock", [7u8; 32], ts("2026-04-28T00:00:00Z"));
        let receipt = unsigned_receipt("evt-mock-kms-001").sign(&s).unwrap();
        assert_eq!(receipt.signature.key_id, s.current_key_id());
        receipt
            .verify(&s.current_public_hex())
            .expect("receipt must verify under the MockKms keyring's current pubkey");
    }

    #[test]
    fn audit_event_round_trip_through_mock_kms() {
        let s = MockKmsSigner::new("audit-mock", [11u8; 32], ts("2026-04-28T00:00:00Z"));
        let signed = SignedAuditEvent::sign(audit_event(1), &s).unwrap();
        assert_eq!(signed.signature.key_id, s.current_key_id());
        signed
            .verify_signature(&s.current_public_hex())
            .expect("audit event must verify under MockKms keyring");
    }

    #[test]
    fn decision_token_round_trip_through_mock_kms() {
        // DecisionToken records signing_pubkey_hex inline, so the
        // verifier doesn't need keyring access at all.
        let s = MockKmsSigner::new("decision-mock", [9u8; 32], ts("2026-04-28T00:00:00Z"));
        let payload =
            decision_payload("c0bd2fab4a7d4686d686edcc9c8356315cd66b820a2072493bf758a1eeb500db");
        let token = payload.sign(&s).unwrap();
        assert_eq!(token.signing_pubkey_hex, s.current_public_hex());
        token.verify().expect("decision token must verify");
    }

    #[test]
    fn pre_rotation_receipt_still_verifies_after_rotation() {
        // End-to-end version of the keyring's
        // `old_signature_still_verifies_after_rotation` — but exercised
        // through the receipt code path. A receipt signed by v1 must keep
        // verifying after the keyring rotates to v2, as long as the
        // verifier uses the *v1* pubkey resolved via key_id.
        let mut s = MockKmsSigner::new("decision-mock", [7u8; 32], ts("2026-04-28T00:00:00Z"));
        let v1_pub = s.current_public_hex();
        let receipt_v1 = unsigned_receipt("evt-mock-kms-001").sign(&s).unwrap();
        assert_eq!(receipt_v1.signature.key_id, "decision-mock-v1");

        s.rotate();
        let v2_pub = s.current_public_hex();
        assert_ne!(v1_pub, v2_pub);

        // Resolve the historic pubkey via the keyring; verify succeeds.
        let resolved = s
            .key_by_id(&receipt_v1.signature.key_id)
            .expect("keyring still knows about v1");
        receipt_v1
            .verify(&resolved.public_hex)
            .expect("pre-rotation receipt must verify under the resolved v1 pubkey");

        // And new receipts signed after rotation embed the v2 key_id.
        let receipt_v2 = unsigned_receipt("evt-mock-kms-002").sign(&s).unwrap();
        assert_eq!(receipt_v2.signature.key_id, "decision-mock-v2");
        receipt_v2
            .verify(&v2_pub)
            .expect("v2 receipt must verify under v2 pubkey");
        // Cross-pollination must fail: v1 receipt MUST NOT verify under v2.
        assert!(receipt_v1.verify(&v2_pub).is_err());
    }
}