cyphr_storage/import.rs
1//! Import utilities for loading Principals from stored entries.
2//!
3//! This module provides functions to reconstruct a `Principal` from stored
4//! entries, supporting both full replay from genesis and partial replay
5//! from a trusted checkpoint.
6//!
7//! Supports both legacy flat format (one cz per line) and commit-based format
8//! (one commit bundle per line).
9
10use crate::{CommitEntry, Entry, KeyEntry};
11use coz::Thumbprint;
12use cyphr::state::{AuthRoot, PrincipalGenesis};
13use cyphr::{Key, Principal};
14
15// ============================================================================
16// Types
17// ============================================================================
18
19/// How the principal was created (genesis mode).
20///
21/// Per SPEC §5, principals can be created implicitly (single key, no coz)
22/// or explicitly (multiple keys with genesis cozies).
23#[derive(Debug, Clone)]
24pub enum Genesis {
25 /// Implicit genesis: single key, no coz required.
26 ///
27 /// Per SPEC §5.1: "Identity emerges from first key possession"
28 /// - `PS = AS = KS = tmb` (PR is None at L1/L2)
29 Implicit(Key),
30
31 /// Explicit genesis: multiple keys established at creation.
32 ///
33 /// Per SPEC §5.1: "Multi-key accounts require explicit genesis"
34 /// - PR is established by principal/create
35 Explicit(Vec<Key>),
36}
37
38/// Trusted checkpoint for partial replay.
39///
40/// Enables thin clients to verify only recent cozies without
41/// replaying full history from genesis.
42///
43/// # Security
44///
45/// The caller must establish trust in this checkpoint before using it.
46/// Per SPEC §6.3.3, checkpoint trust is established via signature by
47/// a key the client trusts (self, service, or cross-attestation).
48#[derive(Debug, Clone)]
49pub struct Checkpoint {
50 /// The trusted Auth State at checkpoint.
51 pub auth_root: AuthRoot,
52 /// Active keys at checkpoint (needed to verify subsequent cozies).
53 pub keys: Vec<Key>,
54 /// Future: thumbprint of key that attested this checkpoint.
55 ///
56 /// Not currently verified; included for forward compatibility.
57 pub attestor: Option<Thumbprint>,
58}
59
60/// Errors that can occur during import.
61#[derive(Debug, thiserror::Error)]
62pub enum LoadError {
63 /// No keys provided for genesis.
64 #[error("genesis requires at least one key")]
65 NoGenesisKeys,
66
67 /// Entry missing required pay.now field.
68 #[error("entry missing pay.now field at index {index}")]
69 MissingTimestamp { index: usize },
70
71 /// Entry missing required signature.
72 #[error("entry missing sig field at index {index}")]
73 MissingSig { index: usize },
74
75 /// Signature verification failed.
76 #[error("invalid signature at index {index}: {message}")]
77 InvalidSignature { index: usize, message: String },
78
79 /// ParsedCoz pre field doesn't match expected AS.
80 #[error("broken chain at index {index}: pre mismatch")]
81 BrokenChain { index: usize },
82
83 /// Unknown signer key.
84 #[error("unknown signer at index {index}: {tmb}")]
85 UnknownSigner { index: usize, tmb: String },
86
87 /// Protocol error from cyphr.
88 #[error("protocol error: {0}")]
89 Protocol(#[from] cyphr::Error),
90
91 /// JSON parsing error.
92 #[error("JSON error at index {index}: {source}")]
93 Json {
94 index: usize,
95 #[source]
96 source: serde_json::Error,
97 },
98
99 /// Unsupported cryptographic algorithm.
100 #[error("unsupported algorithm")]
101 UnsupportedAlgorithm,
102
103 /// Invalid key material (e.g., base64 decode failure).
104 #[error("invalid key material: {field}: {message}")]
105 InvalidKeyMaterial { field: String, message: String },
106}
107
108/// Determine if a typ string represents a transaction (not an action).
109///
110/// Transactions are: key/*, principal/create, commit/create
111/// Everything else is an action.
112fn is_transaction_typ(typ: &str) -> bool {
113 typ.contains("/key/") || typ.contains("/principal/create") || typ.contains("/commit/create")
114}
115
116// ============================================================================
117// Import Functions
118// ============================================================================
119
120/// Load a principal by replaying entries from genesis.
121///
122/// This performs full verification of the entire coz history.
123///
124/// # Arguments
125///
126/// * `genesis` - How the principal was created (implicit or explicit)
127/// * `entries` - All cozies and actions to replay
128///
129/// # Errors
130///
131/// Returns `LoadError` if:
132/// - Signature verification fails
133/// - ParsedCoz chain is broken (pre mismatch)
134/// - Unknown signer key
135///
136/// # Example
137///
138/// ```ignore
139/// // Ignored: requires store context and key material not available in doc-test
140/// let genesis = Genesis::Implicit(my_key);
141/// let entries = store.get_entries(&pr)?;
142/// let principal = load_principal(genesis, &entries)?;
143/// ```
144pub fn load_principal(genesis: Genesis, entries: &[Entry]) -> Result<Principal, LoadError> {
145 // Create principal from genesis
146 let mut principal = match genesis {
147 Genesis::Implicit(key) => Principal::implicit(key)?,
148 Genesis::Explicit(keys) => {
149 if keys.is_empty() {
150 return Err(LoadError::NoGenesisKeys);
151 }
152 Principal::explicit(keys)?
153 },
154 };
155
156 // Replay entries
157 replay_entries(&mut principal, entries)?;
158
159 Ok(principal)
160}
161
162/// Load a principal from a trusted checkpoint.
163///
164/// This allows verification of only the coz suffix, enabling
165/// efficient sync for thin clients or after long periods offline.
166///
167/// # Security
168///
169/// The `expected_pr` parameter is required to prevent identity confusion
170/// attacks. The caller must know which principal they are loading.
171///
172/// # Arguments
173///
174/// * `expected_pr` - The expected Principal Root (for security validation)
175/// * `checkpoint` - Trusted state to start from
176/// * `entries` - Entries after the checkpoint to replay
177///
178/// # Example
179///
180/// ```ignore
181/// let checkpoint = Checkpoint {
182/// auth_root: trusted_as,
183/// keys: current_keys,
184/// attestor: Some(service_tmb),
185/// };
186/// let entries = store.get_entries_range(&pr, &QueryOpts { after: Some(cp_time), .. })?;
187/// let principal = load_from_checkpoint(pr, checkpoint, &entries)?;
188/// ```
189pub fn load_from_checkpoint(
190 expected_pr: Option<PrincipalGenesis>,
191 checkpoint: Checkpoint,
192 entries: &[Entry],
193) -> Result<Principal, LoadError> {
194 if checkpoint.keys.is_empty() {
195 return Err(LoadError::NoGenesisKeys);
196 }
197
198 // Construct principal at checkpoint state
199 // We use the first key to determine hash algorithm, then add remaining keys
200 let mut principal =
201 Principal::from_checkpoint(expected_pr, checkpoint.auth_root, checkpoint.keys)?;
202
203 // Replay entries from checkpoint
204 replay_entries(&mut principal, entries)?;
205
206 Ok(principal)
207}
208
209/// Load a principal by replaying commit bundles from genesis.
210///
211/// This is the commit-based equivalent of `load_principal`, used when
212/// storage contains one commit per line (per SPEC §4.2.1, §7.3.1).
213///
214/// # Arguments
215///
216/// * `genesis` - How the principal was created (implicit or explicit)
217/// * `commits` - Commit bundles to replay
218///
219/// # Errors
220///
221/// Returns `LoadError` if:
222/// - Signature verification fails
223/// - ParsedCoz chain is broken (pre mismatch)
224/// - Unknown signer key
225///
226/// # Example
227///
228/// ```ignore
229/// // Ignored: requires file_store context not available in doc-test
230/// let genesis = Genesis::Implicit(my_key);
231/// let commits = file_store.get_commits(&pr)?;
232/// let principal = load_principal_from_commits(genesis, &commits)?;
233/// ```
234pub fn load_principal_from_commits(
235 genesis: Genesis,
236 commits: &[CommitEntry],
237) -> Result<Principal, LoadError> {
238 // Create principal from genesis
239 let mut principal = load_principal(genesis, &[])?; // Load from genesis, no flat entries
240
241 // Replay commits
242 replay_commits(&mut principal, commits)?;
243
244 Ok(principal)
245}
246
247/// Replay entries onto a principal (shared logic).
248fn replay_entries(principal: &mut Principal, entries: &[Entry]) -> Result<(), LoadError> {
249 use coz::base64ct::{Base64UrlUnpadded, Encoding};
250
251 for (index, entry) in entries.iter().enumerate() {
252 // Parse entry for field access (NOT for czd computation)
253 let raw = entry
254 .as_value()
255 .map_err(|_| LoadError::MissingTimestamp { index })?;
256
257 let pay = raw
258 .get("pay")
259 .ok_or(LoadError::MissingTimestamp { index })?;
260
261 let sig_b64 = raw
262 .get("sig")
263 .and_then(|s| s.as_str())
264 .ok_or(LoadError::MissingSig { index })?;
265
266 let sig =
267 Base64UrlUnpadded::decode_vec(sig_b64).map_err(|_| LoadError::InvalidSignature {
268 index,
269 message: "invalid base64 signature".into(),
270 })?;
271
272 // CRITICAL: Use pay_bytes() for bit-perfect JSON, NOT re-serialization
273 let pay_json = entry
274 .pay_bytes()
275 .map_err(|_| LoadError::MissingTimestamp { index })?;
276
277 // Determine if this is a coz or action by typ prefix
278 let typ = pay.get("typ").and_then(|t| t.as_str()).unwrap_or("");
279
280 if is_transaction_typ(typ) {
281 // ParsedCoz: extract key material if present
282 let new_key = extract_key_from_entry(&raw);
283
284 // Compute czd for this entry
285 let czd = compute_czd(&pay_json, &sig, principal)?;
286
287 // Apply coz
288 principal
289 .verify_and_apply_transaction(&pay_json, &sig, czd, new_key)
290 .map_err(|e| match e {
291 cyphr::Error::InvalidSignature => LoadError::InvalidSignature {
292 index,
293 message: "signature verification failed".into(),
294 },
295 cyphr::Error::InvalidPrior => LoadError::BrokenChain { index },
296 cyphr::Error::UnknownKey => LoadError::UnknownSigner {
297 index,
298 tmb: pay
299 .get("tmb")
300 .and_then(|t| t.as_str())
301 .unwrap_or("?")
302 .into(),
303 },
304 other => LoadError::Protocol(other),
305 })?;
306 } else {
307 // Action: compute czd and record
308 let czd = compute_czd(&pay_json, &sig, principal)?;
309
310 principal
311 .verify_and_record_action(&pay_json, &sig, czd)
312 .map_err(|e| match e {
313 cyphr::Error::InvalidSignature => LoadError::InvalidSignature {
314 index,
315 message: "signature verification failed".into(),
316 },
317 cyphr::Error::UnknownKey => LoadError::UnknownSigner {
318 index,
319 tmb: pay
320 .get("tmb")
321 .and_then(|t| t.as_str())
322 .unwrap_or("?")
323 .into(),
324 },
325 other => LoadError::Protocol(other),
326 })?;
327 }
328 }
329
330 Ok(())
331}
332
333/// Replay commit bundles onto a principal (commit-based format).
334///
335/// Each commit bundle contains multiple cozies that form an atomic unit.
336/// Uses `CommitScope` to properly group cozies into commits.
337/// Key material is read from the commit-level `keys[]` array, not from
338/// per-cz embedded fields.
339fn replay_commits(principal: &mut Principal, commits: &[CommitEntry]) -> Result<(), LoadError> {
340 use coz::base64ct::{Base64UrlUnpadded, Encoding};
341
342 for (commit_idx, commit) in commits.iter().enumerate() {
343 // Collect actions to replay after the commit scope is finalized.
344 // Actions don't participate in the commit lifecycle but may appear
345 // in the same bundle.
346 let mut deferred_actions: Vec<(usize, Vec<u8>, Vec<u8>)> = Vec::new();
347
348 // Create a commit scope for this bundle's cozies
349 let mut scope = principal.begin_commit();
350 let mut applied_tx_count = 0;
351
352 // Iterator over commit-level keys — consumed by key-introducing cozies
353 let mut key_iter = commit.keys.iter();
354
355 for (tx_idx, tx_value) in commit.cozies.iter().enumerate() {
356 let index = commit_idx * 1000 + tx_idx; // Composite index for error messages
357
358 let pay = tx_value
359 .get("pay")
360 .ok_or(LoadError::MissingTimestamp { index })?;
361
362 let sig_b64 = tx_value
363 .get("sig")
364 .and_then(|s| s.as_str())
365 .ok_or(LoadError::MissingSig { index })?;
366
367 let sig = Base64UrlUnpadded::decode_vec(sig_b64).map_err(|_| {
368 LoadError::InvalidSignature {
369 index,
370 message: "invalid base64 signature".into(),
371 }
372 })?;
373
374 // Serialize pay for verification (bit-perfect for stored data)
375 let pay_json =
376 serde_json::to_vec(pay).map_err(|e| LoadError::Json { index, source: e })?;
377
378 // Determine if this is a coz or action by typ prefix
379 let typ = pay.get("typ").and_then(|t| t.as_str()).unwrap_or("");
380
381 if is_transaction_typ(typ) {
382 // ParsedCoz: consume next key from commit-level keys if this
383 // is a key-introducing type (key/create, key/replace)
384 let new_key = if is_key_introducing_typ(typ) {
385 key_iter.next().map(key_entry_to_key).transpose()?
386 } else {
387 None
388 };
389
390 // Compute czd via the scope's hash algorithm
391 let alg = match scope.principal_hash_alg() {
392 cyphr::state::HashAlg::Sha256 => "ES256",
393 cyphr::state::HashAlg::Sha384 => "ES384",
394 cyphr::state::HashAlg::Sha512 => "ES512",
395 };
396 let cad = coz::canonical_hash_for_alg(&pay_json, alg, None)
397 .ok_or(LoadError::UnsupportedAlgorithm)?;
398 let czd =
399 coz::czd_for_alg(&cad, &sig, alg).ok_or(LoadError::UnsupportedAlgorithm)?;
400
401 // Verify and apply within the scope
402 scope
403 .verify_and_apply(&pay_json, &sig, czd, new_key)
404 .map_err(|e| match e {
405 cyphr::Error::InvalidSignature => LoadError::InvalidSignature {
406 index,
407 message: "signature verification failed".into(),
408 },
409 cyphr::Error::InvalidPrior => LoadError::BrokenChain { index },
410 cyphr::Error::UnknownKey => LoadError::UnknownSigner {
411 index,
412 tmb: pay
413 .get("tmb")
414 .and_then(|t| t.as_str())
415 .unwrap_or("?")
416 .into(),
417 },
418 other => LoadError::Protocol(other),
419 })?;
420 applied_tx_count += 1;
421 } else {
422 // Action: defer until after scope is finalized
423 deferred_actions.push((index, pay_json, sig));
424 }
425 }
426
427 if applied_tx_count > 0 {
428 // Finalize the commit scope
429 scope.finalize().map_err(LoadError::Protocol)?;
430 } else {
431 // Drop scope without finalize — no cozies were applied
432 drop(scope);
433 }
434
435 // Replay deferred actions on the principal (outside the scope)
436 for (index, pay_json, sig) in deferred_actions {
437 let czd = compute_czd(&pay_json, &sig, principal)?;
438
439 principal
440 .verify_and_record_action(&pay_json, &sig, czd)
441 .map_err(|e| match e {
442 cyphr::Error::InvalidSignature => LoadError::InvalidSignature {
443 index,
444 message: "signature verification failed".into(),
445 },
446 cyphr::Error::UnknownKey => LoadError::UnknownSigner {
447 index,
448 tmb: "?".into(),
449 },
450 other => LoadError::Protocol(other),
451 })?;
452 }
453 }
454
455 Ok(())
456}
457
458/// Returns true if a coz type introduces new key material.
459fn is_key_introducing_typ(typ: &str) -> bool {
460 typ.contains("/key/create") || typ.contains("/key/replace")
461}
462
463/// Convert a commit-level KeyEntry to a Principal Key.
464fn key_entry_to_key(entry: &KeyEntry) -> Result<Key, LoadError> {
465 use coz::base64ct::{Base64UrlUnpadded, Encoding};
466
467 let pub_key = Base64UrlUnpadded::decode_vec(&entry.pub_key).map_err(|e| {
468 LoadError::InvalidKeyMaterial {
469 field: "pub".into(),
470 message: e.to_string(),
471 }
472 })?;
473 let tmb_bytes =
474 Base64UrlUnpadded::decode_vec(&entry.tmb).map_err(|e| LoadError::InvalidKeyMaterial {
475 field: "tmb".into(),
476 message: e.to_string(),
477 })?;
478
479 Ok(Key {
480 alg: entry.alg.clone(),
481 tmb: Thumbprint::from_bytes(tmb_bytes),
482 pub_key,
483 first_seen: entry.now.unwrap_or(0),
484 last_used: None,
485 revocation: None,
486 tag: entry.tag.clone(),
487 })
488}
489
490/// Extract key material from a per-entry embedded `key` field.
491///
492/// Used by `replay_entries()` (legacy flat format) where key material
493/// is embedded in each coz entry. For commit-based format,
494/// use `key_entry_to_key()` with commit-level `keys[]` instead.
495fn extract_key_from_entry(raw: &serde_json::Value) -> Option<Key> {
496 use coz::base64ct::{Base64UrlUnpadded, Encoding};
497
498 let key_obj = raw.get("key")?;
499 let alg = key_obj.get("alg")?.as_str()?;
500 let pub_b64 = key_obj.get("pub")?.as_str()?;
501 let tmb_b64 = key_obj.get("tmb")?.as_str()?;
502
503 let pub_key = Base64UrlUnpadded::decode_vec(pub_b64).ok()?;
504 let tmb_bytes = Base64UrlUnpadded::decode_vec(tmb_b64).ok()?;
505
506 Some(Key {
507 alg: alg.to_string(),
508 tmb: Thumbprint::from_bytes(tmb_bytes),
509 pub_key,
510 first_seen: 0, // Will be set by apply_transaction
511 last_used: None,
512 revocation: None,
513 tag: None,
514 })
515}
516
517/// Compute Coz digest for an entry.
518///
519/// Uses coz library's canonical_hash_for_alg and czd_for_alg to ensure
520/// consistent hash computation matching the signing path.
521fn compute_czd(pay_json: &[u8], sig: &[u8], principal: &Principal) -> Result<coz::Czd, LoadError> {
522 use cyphr::state::HashAlg;
523
524 // Map principal's hash algorithm to coz algorithm name
525 let alg = match principal.hash_alg() {
526 HashAlg::Sha256 => "ES256",
527 HashAlg::Sha384 => "ES384",
528 HashAlg::Sha512 => "ES512",
529 };
530
531 // Compute cad using canonical hash (compacts JSON first)
532 let cad =
533 coz::canonical_hash_for_alg(pay_json, alg, None).ok_or(LoadError::UnsupportedAlgorithm)?;
534
535 // Compute czd using canonical {"cad":"...","sig":"..."} format
536 let czd = coz::czd_for_alg(&cad, sig, alg).ok_or(LoadError::UnsupportedAlgorithm)?;
537
538 Ok(czd)
539}
540
541// ============================================================================
542// Tests
543// ============================================================================
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 fn make_test_key(id: u8) -> Key {
550 Key {
551 alg: "ES256".to_string(),
552 tmb: Thumbprint::from_bytes(vec![id; 32]),
553 pub_key: vec![id; 64],
554 first_seen: 1000,
555 last_used: None,
556 revocation: None,
557 tag: None,
558 }
559 }
560
561 #[test]
562 fn load_implicit_genesis_no_entries() {
563 let key = make_test_key(0xAA);
564 let _expected_tmb = key.tmb.clone();
565
566 let principal = load_principal(Genesis::Implicit(key), &[]).unwrap();
567
568 // Implicit genesis: PR is None at L1
569 assert!(principal.pg().is_none(), "PR should be None at L1");
570 assert_eq!(principal.active_key_count(), 1);
571 }
572
573 #[test]
574 fn load_explicit_genesis_no_entries() {
575 let key1 = make_test_key(0xAA);
576 let key2 = make_test_key(0xBB);
577
578 let principal =
579 load_principal(Genesis::Explicit(vec![key1.clone(), key2.clone()]), &[]).unwrap();
580
581 // Explicit genesis: PR is None (needs principal/create)
582 assert!(
583 principal.pg().is_none(),
584 "PR should be None before principal/create"
585 );
586 assert_eq!(principal.active_key_count(), 2);
587 assert!(principal.is_key_active(&key1.tmb));
588 assert!(principal.is_key_active(&key2.tmb));
589 }
590
591 #[test]
592 fn load_explicit_genesis_empty_keys_fails() {
593 let result = load_principal(Genesis::Explicit(vec![]), &[]);
594 assert!(matches!(result, Err(LoadError::NoGenesisKeys)));
595 }
596
597 #[test]
598 fn checkpoint_empty_keys_fails() {
599 use cyphr::multihash::MultihashDigest;
600 use cyphr::state::HashAlg;
601
602 let pr = PrincipalGenesis::from_bytes(vec![0xAA; 32]);
603 let checkpoint = Checkpoint {
604 auth_root: AuthRoot(MultihashDigest::from_single(
605 HashAlg::Sha256,
606 vec![0xBB; 32],
607 )),
608 keys: vec![],
609 attestor: None,
610 };
611
612 let result = load_from_checkpoint(Some(pr), checkpoint, &[]);
613 assert!(matches!(result, Err(LoadError::NoGenesisKeys)));
614 }
615}