Skip to main content

ratify_protocol/
crypto.rs

1//! Ratify Protocol v1 — hybrid (Ed25519 + ML-DSA-65) crypto primitives.
2//!
3//! Uses:
4//!   - `ed25519-dalek` — audited Ed25519, pure Rust.
5//!   - `fips204` — pure-Rust ML-DSA-65 (FIPS 204), no_std compatible.
6//!
7//! Every sign produces BOTH component signatures. Every verify checks BOTH;
8//! either failure fails the whole signature.
9
10#[cfg(not(feature = "std"))]
11use alloc::{format, string::String, string::ToString, vec::Vec};
12
13use ed25519_dalek::{Signature as EdSignature, Signer as _, SigningKey, Verifier, VerifyingKey};
14use fips204::ml_dsa_65;
15use fips204::traits::{SerDes, Signer as MlSigner, Verifier as MlVerifier};
16use hmac::{Hmac, Mac};
17use sha2::{Digest, Sha256};
18
19use crate::canonical::{
20    encode_bytes_b64, encode_constraints, encode_hybrid_pub_key, encode_i32, encode_i64,
21    encode_str, encode_str_array,
22};
23use crate::types::{
24    DelegationCert, HybridPrivateKey, HybridPublicKey, HybridSignature, KeyRotationStatement,
25    ProofBundle, ReceiptPartySignature, RevocationList, RevocationPush, SessionToken,
26    TransactionReceipt, VerifyResult, WitnessEntry,
27};
28#[cfg(feature = "std")]
29use crate::types::{AgentIdentity, HumanRoot};
30
31type HmacSha256 = Hmac<Sha256>;
32
33// ----------------------------------------------------------------------
34// ID derivation
35// ----------------------------------------------------------------------
36
37/// `hex(SHA-256(ed25519_pub || ml_dsa_65_pub)[:16])`.
38pub fn derive_id(pub_key: &HybridPublicKey) -> String {
39    let mut hasher = Sha256::new();
40    hasher.update(&pub_key.ed25519);
41    hasher.update(&pub_key.ml_dsa_65);
42    let digest = hasher.finalize();
43    hex::encode(&digest[..16])
44}
45
46// ----------------------------------------------------------------------
47// Keypair generation
48// ----------------------------------------------------------------------
49
50/// Fresh hybrid keypair from OS randomness. Two independent seeds.
51pub fn generate_hybrid_keypair() -> (HybridPublicKey, HybridPrivateKey) {
52    use rand_core::OsRng;
53    let mut seed = [0u8; 32];
54    use rand_core::RngCore;
55    OsRng.fill_bytes(&mut seed);
56    let ed_sk = SigningKey::from_bytes(&seed);
57    let ed_pk = ed_sk.verifying_key();
58
59    let (ml_pk, ml_sk) = ml_dsa_65::try_keygen().expect("ML-DSA-65 keygen");
60
61    (
62        HybridPublicKey {
63            ed25519: ed_pk.to_bytes().to_vec(),
64            ml_dsa_65: ml_pk.into_bytes().to_vec(),
65        },
66        HybridPrivateKey {
67            ed25519: seed.to_vec(),
68            ml_dsa_65: ml_sk.into_bytes().to_vec(),
69        },
70    )
71}
72
73/// Generate a fresh HumanRoot (public + private).
74///
75/// Only available with the `std` feature (uses `SystemTime::now()` for
76/// `created_at`). In no_std environments, build the struct directly and
77/// supply your own timestamp.
78#[cfg(feature = "std")]
79pub fn generate_human_root() -> (HumanRoot, HybridPrivateKey) {
80    let (pub_key, priv_key) = generate_hybrid_keypair();
81    let id = derive_id(&pub_key);
82    (
83        HumanRoot {
84            id,
85            public_key: pub_key,
86            created_at: now_unix(),
87            anchors: None,
88        },
89        priv_key,
90    )
91}
92
93/// Generate a fresh AgentIdentity.
94///
95/// Only available with the `std` feature (uses `SystemTime::now()` for
96/// `created_at`). In no_std environments, build the struct directly and
97/// supply your own timestamp.
98#[cfg(feature = "std")]
99pub fn generate_agent(name: &str, agent_type: &str) -> (AgentIdentity, HybridPrivateKey) {
100    let (pub_key, priv_key) = generate_hybrid_keypair();
101    let id = derive_id(&pub_key);
102    (
103        AgentIdentity {
104            id,
105            public_key: pub_key,
106            name: name.to_string(),
107            agent_type: agent_type.to_string(),
108            created_at: now_unix(),
109        },
110        priv_key,
111    )
112}
113
114/// Current time as Unix seconds. Only available with the `std` feature.
115#[cfg(feature = "std")]
116fn now_unix() -> i64 {
117    use std::time::{SystemTime, UNIX_EPOCH};
118    SystemTime::now()
119        .duration_since(UNIX_EPOCH)
120        .unwrap_or_default()
121        .as_secs() as i64
122}
123
124// ----------------------------------------------------------------------
125// Canonical signing bytes — MUST match Go reference byte-for-byte.
126// ----------------------------------------------------------------------
127
128/// Canonical bytes signed to produce DelegationCert.signature.
129///
130/// `constraints` is always serialized — as `[]` when empty — so canonical
131/// bytes are deterministic across issuers and cross-SDK.
132/// Keys in lex order: cert_id, constraints, expires_at, issued_at, issuer_id,
133/// issuer_pub_key, scope, subject_id, subject_pub_key, version.
134pub fn delegation_sign_bytes(cert: &DelegationCert) -> Vec<u8> {
135    let mut out = String::new();
136    out.push('{');
137    out.push_str("\"cert_id\":");  encode_str(&cert.cert_id, &mut out);
138    out.push_str(",\"constraints\":");  encode_constraints(&cert.constraints, &mut out);
139    out.push_str(",\"expires_at\":");  encode_i64(cert.expires_at, &mut out);
140    out.push_str(",\"issued_at\":");   encode_i64(cert.issued_at, &mut out);
141    out.push_str(",\"issuer_id\":");   encode_str(&cert.issuer_id, &mut out);
142    out.push_str(",\"issuer_pub_key\":");  encode_hybrid_pub_key(&cert.issuer_pub_key, &mut out);
143    out.push_str(",\"scope\":");       encode_str_array(&cert.scope, &mut out);
144    out.push_str(",\"subject_id\":");  encode_str(&cert.subject_id, &mut out);
145    out.push_str(",\"subject_pub_key\":");  encode_hybrid_pub_key(&cert.subject_pub_key, &mut out);
146    out.push_str(",\"version\":");     encode_i32(cert.version, &mut out);
147    out.push('}');
148    out.into_bytes()
149}
150
151/// Canonical bytes signed to produce ProofBundle.challenge_sig.
152///
153/// NOT JSON. Raw binary: challenge || big-endian uint64(ts).
154pub fn challenge_sign_bytes(challenge: &[u8], ts: i64) -> Vec<u8> {
155    challenge_sign_bytes_with_stream(challenge, ts, &[], &[], 0)
156}
157
158/// v1.1 session-bound challenge signable bytes:
159/// challenge || big-endian uint64(ts) || session_context.
160pub fn challenge_sign_bytes_with_session_context(
161    challenge: &[u8],
162    ts: i64,
163    session_context: &[u8],
164) -> Vec<u8> {
165    challenge_sign_bytes_with_stream(challenge, ts, session_context, &[], 0)
166}
167
168/// v1.1 stream-bound challenge signable bytes. Layout:
169/// `challenge || big-endian uint64(ts) || [session_context] || stream_id || big-endian int64(stream_seq)`.
170///
171/// `session_context` may be empty or 32 bytes; `stream_id` may be empty (no
172/// stream binding) or 32 bytes. When `stream_id` is empty, `stream_seq` is
173/// ignored.
174pub fn challenge_sign_bytes_with_stream(
175    challenge: &[u8],
176    ts: i64,
177    session_context: &[u8],
178    stream_id: &[u8],
179    stream_seq: i64,
180) -> Vec<u8> {
181    let stream_len = if stream_id.is_empty() {
182        0
183    } else {
184        stream_id.len() + 8
185    };
186    let mut out = Vec::with_capacity(challenge.len() + 8 + session_context.len() + stream_len);
187    out.extend_from_slice(challenge);
188    out.extend_from_slice(&(ts as u64).to_be_bytes());
189    out.extend_from_slice(session_context);
190    if !stream_id.is_empty() {
191        out.extend_from_slice(stream_id);
192        out.extend_from_slice(&(stream_seq as u64).to_be_bytes());
193    }
194    out
195}
196
197/// Canonical bytes signed to produce RevocationList.signature.
198/// Keys: issuer_id, revoked_certs, updated_at.
199pub fn revocation_sign_bytes(list: &RevocationList) -> Vec<u8> {
200    let mut out = String::new();
201    out.push('{');
202    out.push_str("\"issuer_id\":");    encode_str(&list.issuer_id, &mut out);
203    out.push_str(",\"revoked_certs\":");  encode_str_array(&list.revoked_certs, &mut out);
204    out.push_str(",\"updated_at\":");  encode_i64(list.updated_at, &mut out);
205    out.push('}');
206    out.into_bytes()
207}
208
209/// Canonical bytes signed by both old and new keys in KeyRotationStatement.
210/// Keys: new_id, new_pub_key, old_id, old_pub_key, reason, rotated_at, version.
211pub fn key_rotation_sign_bytes(stmt: &KeyRotationStatement) -> Vec<u8> {
212    let mut out = String::new();
213    out.push('{');
214    out.push_str("\"new_id\":");      encode_str(&stmt.new_id, &mut out);
215    out.push_str(",\"new_pub_key\":"); encode_hybrid_pub_key(&stmt.new_pub_key, &mut out);
216    out.push_str(",\"old_id\":");      encode_str(&stmt.old_id, &mut out);
217    out.push_str(",\"old_pub_key\":"); encode_hybrid_pub_key(&stmt.old_pub_key, &mut out);
218    out.push_str(",\"reason\":");     encode_str(&stmt.reason, &mut out);
219    out.push_str(",\"rotated_at\":"); encode_i64(stmt.rotated_at, &mut out);
220    out.push_str(",\"version\":");    encode_i32(stmt.version, &mut out);
221    out.push('}');
222    out.into_bytes()
223}
224
225// ----------------------------------------------------------------------
226// Hybrid sign / verify
227// ----------------------------------------------------------------------
228
229/// Produce a hybrid signature. Both components over identical `msg`.
230pub fn sign_both(msg: &[u8], priv_key: &HybridPrivateKey) -> HybridSignature {
231    let mut ed_seed = [0u8; 32];
232    ed_seed.copy_from_slice(&priv_key.ed25519[..32]);
233    let ed_sk = SigningKey::from_bytes(&ed_seed);
234    let ed_sig = ed_sk.sign(msg);
235
236    let mut ml_sk_bytes = [0u8; ml_dsa_65::SK_LEN];
237    ml_sk_bytes.copy_from_slice(&priv_key.ml_dsa_65);
238    let ml_sk = ml_dsa_65::PrivateKey::try_from_bytes(ml_sk_bytes)
239        .expect("ML-DSA-65 secret key malformed");
240    let ml_sig = ml_sk.try_sign(msg, &[]).expect("ML-DSA-65 sign");
241
242    HybridSignature {
243        ed25519: ed_sig.to_bytes().to_vec(),
244        ml_dsa_65: ml_sig.to_vec(),
245    }
246}
247
248/// Verify both components. Returns Ok iff both verify; Err with diagnostic.
249pub fn verify_both(
250    msg: &[u8],
251    sig: &HybridSignature,
252    pub_key: &HybridPublicKey,
253) -> Result<(), String> {
254    if pub_key.ed25519.len() != 32 {
255        return Err(format!(
256            "Ed25519 public key wrong length: {}",
257            pub_key.ed25519.len()
258        ));
259    }
260    if pub_key.ml_dsa_65.len() != 1952 {
261        return Err(format!(
262            "ML-DSA-65 public key wrong length: {}",
263            pub_key.ml_dsa_65.len()
264        ));
265    }
266    if sig.ed25519.len() != 64 {
267        return Err(format!(
268            "Ed25519 signature wrong length: {}",
269            sig.ed25519.len()
270        ));
271    }
272    if sig.ml_dsa_65.len() != 3309 {
273        return Err(format!(
274            "ML-DSA-65 signature wrong length: {}",
275            sig.ml_dsa_65.len()
276        ));
277    }
278
279    let mut ed_pk_bytes = [0u8; 32];
280    ed_pk_bytes.copy_from_slice(&pub_key.ed25519);
281    let ed_pk = VerifyingKey::from_bytes(&ed_pk_bytes)
282        .map_err(|_| "Ed25519 public key invalid".to_string())?;
283    let ed_sig = EdSignature::from_slice(&sig.ed25519)
284        .map_err(|_| "Ed25519 signature invalid".to_string())?;
285    ed_pk
286        .verify(msg, &ed_sig)
287        .map_err(|_| "Ed25519 signature invalid".to_string())?;
288
289    let mut ml_pk_bytes = [0u8; ml_dsa_65::PK_LEN];
290    ml_pk_bytes.copy_from_slice(&pub_key.ml_dsa_65);
291    let ml_pk = ml_dsa_65::PublicKey::try_from_bytes(ml_pk_bytes)
292        .map_err(|_| "ML-DSA-65 public key malformed".to_string())?;
293    let mut ml_sig_bytes = [0u8; ml_dsa_65::SIG_LEN];
294    ml_sig_bytes.copy_from_slice(&sig.ml_dsa_65);
295    if !ml_pk.verify(msg, &ml_sig_bytes, &[]) {
296        return Err("ML-DSA-65 signature invalid".to_string());
297    }
298
299    Ok(())
300}
301
302// ----------------------------------------------------------------------
303// High-level sign/verify helpers
304// ----------------------------------------------------------------------
305
306pub fn issue_delegation(cert: &mut DelegationCert, issuer_priv: &HybridPrivateKey) {
307    cert.signature = sign_both(&delegation_sign_bytes(cert), issuer_priv);
308}
309
310pub fn verify_delegation_signature(cert: &DelegationCert) -> bool {
311    verify_delegation_signature_e(cert).is_ok()
312}
313
314pub fn verify_delegation_signature_e(cert: &DelegationCert) -> Result<(), String> {
315    verify_both(
316        &delegation_sign_bytes(cert),
317        &cert.signature,
318        &cert.issuer_pub_key,
319    )
320}
321
322pub fn sign_challenge(challenge: &[u8], ts: i64, agent_priv: &HybridPrivateKey) -> HybridSignature {
323    sign_challenge_with_session_context(challenge, ts, &[], agent_priv)
324}
325
326pub fn sign_challenge_with_session_context(
327    challenge: &[u8],
328    ts: i64,
329    session_context: &[u8],
330    agent_priv: &HybridPrivateKey,
331) -> HybridSignature {
332    assert!(
333        session_context.is_empty() || session_context.len() == 32,
334        "session_context must be 32 bytes"
335    );
336    sign_both(
337        &challenge_sign_bytes_with_session_context(challenge, ts, session_context),
338        agent_priv,
339    )
340}
341
342pub fn sign_challenge_with_stream(
343    challenge: &[u8],
344    ts: i64,
345    session_context: &[u8],
346    stream_id: &[u8],
347    stream_seq: i64,
348    agent_priv: &HybridPrivateKey,
349) -> HybridSignature {
350    assert!(
351        session_context.is_empty() || session_context.len() == 32,
352        "session_context must be 32 bytes"
353    );
354    assert_eq!(stream_id.len(), 32, "stream_id must be 32 bytes");
355    assert!(stream_seq >= 1, "stream_seq must be >=1");
356    sign_both(
357        &challenge_sign_bytes_with_stream(challenge, ts, session_context, stream_id, stream_seq),
358        agent_priv,
359    )
360}
361
362pub fn verify_challenge_signature(
363    challenge: &[u8],
364    ts: i64,
365    sig: &HybridSignature,
366    agent_pub: &HybridPublicKey,
367) -> Result<(), String> {
368    verify_challenge_signature_with_stream(challenge, ts, &[], &[], 0, sig, agent_pub)
369}
370
371pub fn verify_challenge_signature_with_session_context(
372    challenge: &[u8],
373    ts: i64,
374    session_context: &[u8],
375    sig: &HybridSignature,
376    agent_pub: &HybridPublicKey,
377) -> Result<(), String> {
378    verify_challenge_signature_with_stream(challenge, ts, session_context, &[], 0, sig, agent_pub)
379}
380
381pub fn verify_challenge_signature_with_stream(
382    challenge: &[u8],
383    ts: i64,
384    session_context: &[u8],
385    stream_id: &[u8],
386    stream_seq: i64,
387    sig: &HybridSignature,
388    agent_pub: &HybridPublicKey,
389) -> Result<(), String> {
390    if !session_context.is_empty() && session_context.len() != 32 {
391        return Err(format!(
392            "session_context must be 32 bytes, got {}",
393            session_context.len()
394        ));
395    }
396    if !stream_id.is_empty() && stream_id.len() != 32 {
397        return Err(format!(
398            "stream_id must be 32 bytes, got {}",
399            stream_id.len()
400        ));
401    }
402    if !stream_id.is_empty() && stream_seq < 1 {
403        return Err(format!("stream_seq must be >=1, got {}", stream_seq));
404    }
405    verify_both(
406        &challenge_sign_bytes_with_stream(challenge, ts, session_context, stream_id, stream_seq),
407        sig,
408        agent_pub,
409    )
410}
411
412pub fn issue_revocation_list(list: &mut RevocationList, issuer_priv: &HybridPrivateKey) {
413    list.signature = sign_both(&revocation_sign_bytes(list), issuer_priv);
414}
415
416pub fn verify_revocation_list(list: &RevocationList, issuer_pub: &HybridPublicKey) -> bool {
417    verify_both(&revocation_sign_bytes(list), &list.signature, issuer_pub).is_ok()
418}
419
420/// Canonical bytes signed to produce RevocationPush.signature.
421/// Keys: entries, issuer_id, pushed_at, seq_no.
422pub fn revocation_push_sign_bytes(push: &RevocationPush) -> Vec<u8> {
423    let mut out = String::new();
424    out.push('{');
425    out.push_str("\"entries\":");    encode_str_array(&push.entries, &mut out);
426    out.push_str(",\"issuer_id\":"); encode_str(&push.issuer_id, &mut out);
427    out.push_str(",\"pushed_at\":"); encode_i64(push.pushed_at, &mut out);
428    out.push_str(",\"seq_no\":");   encode_i64(push.seq_no, &mut out);
429    out.push('}');
430    out.into_bytes()
431}
432
433pub fn issue_revocation_push(push: &mut RevocationPush, issuer_priv: &HybridPrivateKey) {
434    push.signature = sign_both(&revocation_push_sign_bytes(push), issuer_priv);
435}
436
437pub fn verify_revocation_push(push: &RevocationPush, issuer_pub: &HybridPublicKey) -> bool {
438    verify_both(
439        &revocation_push_sign_bytes(push),
440        &push.signature,
441        issuer_pub,
442    )
443    .is_ok()
444}
445
446/// Canonical bytes signed to produce WitnessEntry.signature.
447/// Keys: entry_data, prev_hash, timestamp, witness_id.
448pub fn witness_entry_sign_bytes(entry: &WitnessEntry) -> Vec<u8> {
449    let mut out = String::new();
450    out.push('{');
451    out.push_str("\"entry_data\":"); encode_bytes_b64(&entry.entry_data, &mut out);
452    out.push_str(",\"prev_hash\":"); encode_bytes_b64(&entry.prev_hash, &mut out);
453    out.push_str(",\"timestamp\":"); encode_i64(entry.timestamp, &mut out);
454    out.push_str(",\"witness_id\":"); encode_str(&entry.witness_id, &mut out);
455    out.push('}');
456    out.into_bytes()
457}
458
459pub fn issue_witness_entry(entry: &mut WitnessEntry, witness_priv: &HybridPrivateKey) {
460    entry.signature = sign_both(&witness_entry_sign_bytes(entry), witness_priv);
461}
462
463pub fn verify_witness_entry(entry: &WitnessEntry, witness_pub: &HybridPublicKey) -> bool {
464    verify_both(
465        &witness_entry_sign_bytes(entry),
466        &entry.signature,
467        witness_pub,
468    )
469    .is_ok()
470}
471
472pub fn issue_key_rotation_statement(
473    stmt: &mut KeyRotationStatement,
474    old_priv: &HybridPrivateKey,
475    new_priv: &HybridPrivateKey,
476) {
477    let bytes = key_rotation_sign_bytes(stmt);
478    stmt.signature_old = sign_both(&bytes, old_priv);
479    stmt.signature_new = sign_both(&bytes, new_priv);
480}
481
482pub fn verify_key_rotation_statement(stmt: &KeyRotationStatement) -> Result<(), String> {
483    if stmt.version != 1 {
484        return Err(format!(
485            "version_mismatch: unsupported version {}",
486            stmt.version
487        ));
488    }
489    if stmt.old_id != derive_id(&stmt.old_pub_key) {
490        return Err("old_id does not match old_pub_key".to_string());
491    }
492    if stmt.new_id != derive_id(&stmt.new_pub_key) {
493        return Err("new_id does not match new_pub_key".to_string());
494    }
495    if stmt.old_id == stmt.new_id {
496        return Err("old_id and new_id must differ".to_string());
497    }
498    if !is_key_rotation_reason_known(&stmt.reason) {
499        return Err(format!("unknown key rotation reason: {}", stmt.reason));
500    }
501    let bytes = key_rotation_sign_bytes(stmt);
502    verify_both(&bytes, &stmt.signature_old, &stmt.old_pub_key)
503        .map_err(|e| format!("old signature invalid: {}", e))?;
504    verify_both(&bytes, &stmt.signature_new, &stmt.new_pub_key)
505        .map_err(|e| format!("new signature invalid: {}", e))?;
506    Ok(())
507}
508
509fn is_key_rotation_reason_known(reason: &str) -> bool {
510    matches!(
511        reason,
512        "routine" | "compromise_suspected" | "device_lost" | "recovery" | "other"
513    )
514}
515
516/// 32 cryptographically random bytes from OS RNG.
517pub fn generate_challenge() -> Vec<u8> {
518    use rand_core::{OsRng, RngCore};
519    let mut b = [0u8; 32];
520    OsRng.fill_bytes(&mut b);
521    b.to_vec()
522}
523
524// ----------------------------------------------------------------------
525// v1.1 transaction receipts
526// ----------------------------------------------------------------------
527
528/// Canonical bytes that every party signs to bind a TransactionReceipt.
529/// Parties are sorted lex by party_id; party object keys: agent_id,
530/// agent_pub_key, party_id, role.
531/// Outer keys: created_at, parties, terms_canonical_json, terms_schema_uri,
532/// transaction_id, version.
533pub fn transaction_receipt_sign_bytes(receipt: &TransactionReceipt) -> Vec<u8> {
534    // Sort parties by party_id (lex).
535    let mut sorted_parties: Vec<&crate::types::ReceiptParty> = receipt.parties.iter().collect();
536    sorted_parties.sort_by(|a, b| a.party_id.cmp(&b.party_id));
537
538    let mut out = String::new();
539    out.push('{');
540    out.push_str("\"created_at\":"); encode_i64(receipt.created_at, &mut out);
541    out.push_str(",\"parties\":[");
542    for (i, p) in sorted_parties.iter().enumerate() {
543        if i > 0 {
544            out.push(',');
545        }
546        out.push('{');
547        out.push_str("\"agent_id\":"); encode_str(&p.agent_id, &mut out);
548        out.push_str(",\"agent_pub_key\":"); encode_hybrid_pub_key(&p.agent_pub_key, &mut out);
549        out.push_str(",\"party_id\":"); encode_str(&p.party_id, &mut out);
550        out.push_str(",\"role\":"); encode_str(&p.role, &mut out);
551        out.push('}');
552    }
553    out.push(']');
554    out.push_str(",\"terms_canonical_json\":"); encode_bytes_b64(&receipt.terms_canonical_json, &mut out);
555    out.push_str(",\"terms_schema_uri\":"); encode_str(&receipt.terms_schema_uri, &mut out);
556    out.push_str(",\"transaction_id\":"); encode_str(&receipt.transaction_id, &mut out);
557    out.push_str(",\"version\":"); encode_i32(receipt.version, &mut out);
558    out.push('}');
559    out.into_bytes()
560}
561
562/// Produce a party's hybrid signature over the receipt's canonical signable.
563pub fn sign_transaction_receipt_party(
564    receipt: &TransactionReceipt,
565    party_id: &str,
566    agent_priv: &HybridPrivateKey,
567) -> ReceiptPartySignature {
568    let data = transaction_receipt_sign_bytes(receipt);
569    let sig = sign_both(&data, agent_priv);
570    ReceiptPartySignature {
571        party_id: party_id.to_string(),
572        signature: sig,
573    }
574}
575
576// ----------------------------------------------------------------------
577// v1.1 session cert cache (ROADMAP 2.3)
578// ----------------------------------------------------------------------
579
580/// 32-byte SHA-256 of the concatenated delegation_sign_bytes of each cert.
581/// Used as a stable chain identity inside SessionToken — a cert rotation
582/// changes chain_hash, invalidating every token issued against the old chain.
583pub fn chain_hash(chain: &[DelegationCert]) -> Vec<u8> {
584    let mut hasher = Sha256::new();
585    for cert in chain {
586        hasher.update(delegation_sign_bytes(cert));
587    }
588    hasher.finalize().to_vec()
589}
590
591/// Canonical MAC-input bytes for a SessionToken. The MAC itself is excluded
592/// from the signable (a MAC cannot cover itself).
593/// Keys: agent_id, agent_pub_key, chain_hash, granted_scope, human_id,
594/// issued_at, session_id, valid_until, version.
595pub fn session_token_sign_bytes(token: &SessionToken) -> Vec<u8> {
596    let mut scope = token.granted_scope.clone();
597    scope.sort();
598    let mut out = String::new();
599    out.push('{');
600    out.push_str("\"agent_id\":");     encode_str(&token.agent_id, &mut out);
601    out.push_str(",\"agent_pub_key\":"); encode_hybrid_pub_key(&token.agent_pub_key, &mut out);
602    out.push_str(",\"chain_hash\":");  encode_bytes_b64(&token.chain_hash, &mut out);
603    out.push_str(",\"granted_scope\":"); encode_str_array(&scope, &mut out);
604    out.push_str(",\"human_id\":");    encode_str(&token.human_id, &mut out);
605    out.push_str(",\"issued_at\":");   encode_i64(token.issued_at, &mut out);
606    out.push_str(",\"session_id\":");  encode_str(&token.session_id, &mut out);
607    out.push_str(",\"valid_until\":"); encode_i64(token.valid_until, &mut out);
608    out.push_str(",\"version\":");     encode_i32(token.version, &mut out);
609    out.push('}');
610    out.into_bytes()
611}
612
613/// Issue a SessionToken from a previously verified bundle's result. Callers
614/// MUST only invoke this after verify_bundle returned valid=true.
615pub fn issue_session_token(
616    bundle: &ProofBundle,
617    result: &VerifyResult,
618    session_id: &str,
619    issued_at: i64,
620    valid_until: i64,
621    session_secret: &[u8],
622) -> Result<SessionToken, String> {
623    if session_secret.is_empty() {
624        return Err("session_secret must not be empty".to_string());
625    }
626    if session_id.is_empty() {
627        return Err("session_id must not be empty".to_string());
628    }
629    if valid_until <= issued_at {
630        return Err("valid_until must be strictly after issued_at".to_string());
631    }
632    let mut scope = result.granted_scope.clone();
633    scope.sort();
634    let mut token = SessionToken {
635        version: 1,
636        session_id: session_id.to_string(),
637        agent_id: result.agent_id.clone(),
638        agent_pub_key: bundle.agent_pub_key.clone(),
639        human_id: result.human_id.clone(),
640        granted_scope: scope,
641        issued_at,
642        valid_until,
643        chain_hash: chain_hash(&bundle.delegations),
644        mac: Vec::new(),
645    };
646    let signable = session_token_sign_bytes(&token);
647    let mut mac =
648        HmacSha256::new_from_slice(session_secret).map_err(|e| format!("init HMAC: {}", e))?;
649    mac.update(&signable);
650    token.mac = mac.finalize().into_bytes().to_vec();
651    Ok(token)
652}
653
654/// Check a SessionToken's HMAC against session_secret and its validity
655/// window against `now` (unix seconds). Returns Ok on success.
656pub fn verify_session_token_e(
657    token: &SessionToken,
658    session_secret: &[u8],
659    now: i64,
660) -> Result<(), String> {
661    if session_secret.is_empty() {
662        return Err("session_secret must not be empty".to_string());
663    }
664    if token.version != 1 {
665        return Err(format!(
666            "version_mismatch: unsupported version {}",
667            token.version
668        ));
669    }
670    if token.chain_hash.len() != 32 {
671        return Err(format!(
672            "chain_hash must be 32 bytes, got {}",
673            token.chain_hash.len()
674        ));
675    }
676    if token.mac.len() != 32 {
677        return Err(format!("mac must be 32 bytes, got {}", token.mac.len()));
678    }
679    let mut mac =
680        HmacSha256::new_from_slice(session_secret).map_err(|e| format!("init HMAC: {}", e))?;
681    mac.update(&session_token_sign_bytes(token));
682    mac.verify_slice(&token.mac)
683        .map_err(|_| "session_token MAC invalid".to_string())?;
684    if now < token.issued_at {
685        return Err("session_token not yet valid".to_string());
686    }
687    if now > token.valid_until {
688        return Err("session_token expired".to_string());
689    }
690    Ok(())
691}
692
693pub fn verify_session_token(token: &SessionToken, session_secret: &[u8], now: i64) -> bool {
694    verify_session_token_e(token, session_secret, now).is_ok()
695}