use crate::{CommitEntry, Entry, KeyEntry};
use coz::Thumbprint;
use cyphr::state::{AuthRoot, PrincipalGenesis};
use cyphr::{Key, Principal};
#[derive(Debug, Clone)]
pub enum Genesis {
Implicit(Key),
Explicit(Vec<Key>),
}
#[derive(Debug, Clone)]
pub struct Checkpoint {
pub auth_root: AuthRoot,
pub keys: Vec<Key>,
pub attestor: Option<Thumbprint>,
}
#[derive(Debug, thiserror::Error)]
pub enum LoadError {
#[error("genesis requires at least one key")]
NoGenesisKeys,
#[error("entry missing pay.now field at index {index}")]
MissingTimestamp { index: usize },
#[error("entry missing sig field at index {index}")]
MissingSig { index: usize },
#[error("invalid signature at index {index}: {message}")]
InvalidSignature { index: usize, message: String },
#[error("broken chain at index {index}: pre mismatch")]
BrokenChain { index: usize },
#[error("unknown signer at index {index}: {tmb}")]
UnknownSigner { index: usize, tmb: String },
#[error("protocol error: {0}")]
Protocol(#[from] cyphr::Error),
#[error("JSON error at index {index}: {source}")]
Json {
index: usize,
#[source]
source: serde_json::Error,
},
#[error("unsupported algorithm")]
UnsupportedAlgorithm,
#[error("invalid key material: {field}: {message}")]
InvalidKeyMaterial { field: String, message: String },
}
fn is_transaction_typ(typ: &str) -> bool {
typ.contains("/key/") || typ.contains("/principal/create") || typ.contains("/commit/create")
}
pub fn load_principal(genesis: Genesis, entries: &[Entry]) -> Result<Principal, LoadError> {
let mut principal = match genesis {
Genesis::Implicit(key) => Principal::implicit(key)?,
Genesis::Explicit(keys) => {
if keys.is_empty() {
return Err(LoadError::NoGenesisKeys);
}
Principal::explicit(keys)?
},
};
replay_entries(&mut principal, entries)?;
Ok(principal)
}
pub fn load_from_checkpoint(
expected_pr: Option<PrincipalGenesis>,
checkpoint: Checkpoint,
entries: &[Entry],
) -> Result<Principal, LoadError> {
if checkpoint.keys.is_empty() {
return Err(LoadError::NoGenesisKeys);
}
let mut principal =
Principal::from_checkpoint(expected_pr, checkpoint.auth_root, checkpoint.keys)?;
replay_entries(&mut principal, entries)?;
Ok(principal)
}
pub fn load_principal_from_commits(
genesis: Genesis,
commits: &[CommitEntry],
) -> Result<Principal, LoadError> {
let mut principal = load_principal(genesis, &[])?;
replay_commits(&mut principal, commits)?;
Ok(principal)
}
fn replay_entries(principal: &mut Principal, entries: &[Entry]) -> Result<(), LoadError> {
use coz::base64ct::{Base64UrlUnpadded, Encoding};
for (index, entry) in entries.iter().enumerate() {
let raw = entry
.as_value()
.map_err(|_| LoadError::MissingTimestamp { index })?;
let pay = raw
.get("pay")
.ok_or(LoadError::MissingTimestamp { index })?;
let sig_b64 = raw
.get("sig")
.and_then(|s| s.as_str())
.ok_or(LoadError::MissingSig { index })?;
let sig =
Base64UrlUnpadded::decode_vec(sig_b64).map_err(|_| LoadError::InvalidSignature {
index,
message: "invalid base64 signature".into(),
})?;
let pay_json = entry
.pay_bytes()
.map_err(|_| LoadError::MissingTimestamp { index })?;
let typ = pay.get("typ").and_then(|t| t.as_str()).unwrap_or("");
if is_transaction_typ(typ) {
let new_key = extract_key_from_entry(&raw);
let czd = compute_czd(&pay_json, &sig, principal)?;
principal
.verify_and_apply_transaction(&pay_json, &sig, czd, new_key)
.map_err(|e| match e {
cyphr::Error::InvalidSignature => LoadError::InvalidSignature {
index,
message: "signature verification failed".into(),
},
cyphr::Error::InvalidPrior => LoadError::BrokenChain { index },
cyphr::Error::UnknownKey => LoadError::UnknownSigner {
index,
tmb: pay
.get("tmb")
.and_then(|t| t.as_str())
.unwrap_or("?")
.into(),
},
other => LoadError::Protocol(other),
})?;
} else {
let czd = compute_czd(&pay_json, &sig, principal)?;
principal
.verify_and_record_action(&pay_json, &sig, czd)
.map_err(|e| match e {
cyphr::Error::InvalidSignature => LoadError::InvalidSignature {
index,
message: "signature verification failed".into(),
},
cyphr::Error::UnknownKey => LoadError::UnknownSigner {
index,
tmb: pay
.get("tmb")
.and_then(|t| t.as_str())
.unwrap_or("?")
.into(),
},
other => LoadError::Protocol(other),
})?;
}
}
Ok(())
}
fn replay_commits(principal: &mut Principal, commits: &[CommitEntry]) -> Result<(), LoadError> {
use coz::base64ct::{Base64UrlUnpadded, Encoding};
for (commit_idx, commit) in commits.iter().enumerate() {
let mut deferred_actions: Vec<(usize, Vec<u8>, Vec<u8>)> = Vec::new();
let mut scope = principal.begin_commit();
let mut applied_tx_count = 0;
let mut key_iter = commit.keys.iter();
for (tx_idx, tx_value) in commit.cozies.iter().enumerate() {
let index = commit_idx * 1000 + tx_idx;
let pay = tx_value
.get("pay")
.ok_or(LoadError::MissingTimestamp { index })?;
let sig_b64 = tx_value
.get("sig")
.and_then(|s| s.as_str())
.ok_or(LoadError::MissingSig { index })?;
let sig = Base64UrlUnpadded::decode_vec(sig_b64).map_err(|_| {
LoadError::InvalidSignature {
index,
message: "invalid base64 signature".into(),
}
})?;
let pay_json =
serde_json::to_vec(pay).map_err(|e| LoadError::Json { index, source: e })?;
let typ = pay.get("typ").and_then(|t| t.as_str()).unwrap_or("");
if is_transaction_typ(typ) {
let new_key = if is_key_introducing_typ(typ) {
key_iter.next().map(key_entry_to_key).transpose()?
} else {
None
};
let alg = match scope.principal_hash_alg() {
cyphr::state::HashAlg::Sha256 => "ES256",
cyphr::state::HashAlg::Sha384 => "ES384",
cyphr::state::HashAlg::Sha512 => "ES512",
};
let cad = coz::canonical_hash_for_alg(&pay_json, alg, None)
.ok_or(LoadError::UnsupportedAlgorithm)?;
let czd =
coz::czd_for_alg(&cad, &sig, alg).ok_or(LoadError::UnsupportedAlgorithm)?;
scope
.verify_and_apply(&pay_json, &sig, czd, new_key)
.map_err(|e| match e {
cyphr::Error::InvalidSignature => LoadError::InvalidSignature {
index,
message: "signature verification failed".into(),
},
cyphr::Error::InvalidPrior => LoadError::BrokenChain { index },
cyphr::Error::UnknownKey => LoadError::UnknownSigner {
index,
tmb: pay
.get("tmb")
.and_then(|t| t.as_str())
.unwrap_or("?")
.into(),
},
other => LoadError::Protocol(other),
})?;
applied_tx_count += 1;
} else {
deferred_actions.push((index, pay_json, sig));
}
}
if applied_tx_count > 0 {
scope.finalize().map_err(LoadError::Protocol)?;
} else {
drop(scope);
}
for (index, pay_json, sig) in deferred_actions {
let czd = compute_czd(&pay_json, &sig, principal)?;
principal
.verify_and_record_action(&pay_json, &sig, czd)
.map_err(|e| match e {
cyphr::Error::InvalidSignature => LoadError::InvalidSignature {
index,
message: "signature verification failed".into(),
},
cyphr::Error::UnknownKey => LoadError::UnknownSigner {
index,
tmb: "?".into(),
},
other => LoadError::Protocol(other),
})?;
}
}
Ok(())
}
fn is_key_introducing_typ(typ: &str) -> bool {
typ.contains("/key/create") || typ.contains("/key/replace")
}
fn key_entry_to_key(entry: &KeyEntry) -> Result<Key, LoadError> {
use coz::base64ct::{Base64UrlUnpadded, Encoding};
let pub_key = Base64UrlUnpadded::decode_vec(&entry.pub_key).map_err(|e| {
LoadError::InvalidKeyMaterial {
field: "pub".into(),
message: e.to_string(),
}
})?;
let tmb_bytes =
Base64UrlUnpadded::decode_vec(&entry.tmb).map_err(|e| LoadError::InvalidKeyMaterial {
field: "tmb".into(),
message: e.to_string(),
})?;
Ok(Key {
alg: entry.alg.clone(),
tmb: Thumbprint::from_bytes(tmb_bytes),
pub_key,
first_seen: entry.now.unwrap_or(0),
last_used: None,
revocation: None,
tag: entry.tag.clone(),
})
}
fn extract_key_from_entry(raw: &serde_json::Value) -> Option<Key> {
use coz::base64ct::{Base64UrlUnpadded, Encoding};
let key_obj = raw.get("key")?;
let alg = key_obj.get("alg")?.as_str()?;
let pub_b64 = key_obj.get("pub")?.as_str()?;
let tmb_b64 = key_obj.get("tmb")?.as_str()?;
let pub_key = Base64UrlUnpadded::decode_vec(pub_b64).ok()?;
let tmb_bytes = Base64UrlUnpadded::decode_vec(tmb_b64).ok()?;
Some(Key {
alg: alg.to_string(),
tmb: Thumbprint::from_bytes(tmb_bytes),
pub_key,
first_seen: 0, last_used: None,
revocation: None,
tag: None,
})
}
fn compute_czd(pay_json: &[u8], sig: &[u8], principal: &Principal) -> Result<coz::Czd, LoadError> {
use cyphr::state::HashAlg;
let alg = match principal.hash_alg() {
HashAlg::Sha256 => "ES256",
HashAlg::Sha384 => "ES384",
HashAlg::Sha512 => "ES512",
};
let cad =
coz::canonical_hash_for_alg(pay_json, alg, None).ok_or(LoadError::UnsupportedAlgorithm)?;
let czd = coz::czd_for_alg(&cad, sig, alg).ok_or(LoadError::UnsupportedAlgorithm)?;
Ok(czd)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_key(id: u8) -> Key {
Key {
alg: "ES256".to_string(),
tmb: Thumbprint::from_bytes(vec![id; 32]),
pub_key: vec![id; 64],
first_seen: 1000,
last_used: None,
revocation: None,
tag: None,
}
}
#[test]
fn load_implicit_genesis_no_entries() {
let key = make_test_key(0xAA);
let _expected_tmb = key.tmb.clone();
let principal = load_principal(Genesis::Implicit(key), &[]).unwrap();
assert!(principal.pg().is_none(), "PR should be None at L1");
assert_eq!(principal.active_key_count(), 1);
}
#[test]
fn load_explicit_genesis_no_entries() {
let key1 = make_test_key(0xAA);
let key2 = make_test_key(0xBB);
let principal =
load_principal(Genesis::Explicit(vec![key1.clone(), key2.clone()]), &[]).unwrap();
assert!(
principal.pg().is_none(),
"PR should be None before principal/create"
);
assert_eq!(principal.active_key_count(), 2);
assert!(principal.is_key_active(&key1.tmb));
assert!(principal.is_key_active(&key2.tmb));
}
#[test]
fn load_explicit_genesis_empty_keys_fails() {
let result = load_principal(Genesis::Explicit(vec![]), &[]);
assert!(matches!(result, Err(LoadError::NoGenesisKeys)));
}
#[test]
fn checkpoint_empty_keys_fails() {
use cyphr::multihash::MultihashDigest;
use cyphr::state::HashAlg;
let pr = PrincipalGenesis::from_bytes(vec![0xAA; 32]);
let checkpoint = Checkpoint {
auth_root: AuthRoot(MultihashDigest::from_single(
HashAlg::Sha256,
vec![0xBB; 32],
)),
keys: vec![],
attestor: None,
};
let result = load_from_checkpoint(Some(pr), checkpoint, &[]);
assert!(matches!(result, Err(LoadError::NoGenesisKeys)));
}
}