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