Skip to main content

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}