//! Trust policy management and enforcement (spec §14.5). //! //! A repository with a policy verifies everything it receives: every change //! must be authenticated by a key the policy authorizes for the bookmark, //! every bookmark update must carry an attestation by such a key, and a new //! policy must be signed by a threshold of the roots of the policy it replaces. use super::*; use crate::identity::SecretKey; use crate::policy::{Policy, Require, Rule}; use crate::protocol::SignedPolicy; /// One year, the default policy lifetime. const POLICY_LIFETIME_MS: u64 = 365 * 24 * 60 * 60 * 1000; /// A signed policy update: applied when it met the root threshold, else a /// proposal carrying the signatures gathered so far (spec §14.5). #[derive(Clone, Debug)] pub struct PolicyUpdate { pub policy: Policy, pub sigs: Vec, pub applied: bool, } impl PolicyUpdate { pub fn signed(&self) -> SignedPolicy { SignedPolicy { policy: self.policy.clone(), sigs: self.sigs.clone(), } } } impl Repo { /// The current policy and its signatures, if the repository has one. pub fn current_policy(&self) -> Result> { let Some(r) = self.view()?.policy else { return Ok(None); }; let policy = Policy::decode(&self.store.get_typed(&r, ObjectType::Policy)?)?; Ok(Some(( r, SignedPolicy { policy, sigs: self.store.signatures(&r)?, }, ))) } /// Store a policy with its signatures and point the `policy` reference at it. pub(crate) fn set_policy(&self, sp: &SignedPolicy, command: &str) -> Result { let r = self.store.put(ObjectType::Policy, &sp.policy.encode())?; for sig in &sp.sigs { self.store.put_signature(&r, sig)?; } for id in &sp.policy.identities { self.store.put_identity(id)?; } let dir = self.oxy.join("policies"); fs::create_dir_all(&dir)?; write_atomic( &dir.join(format!("{:020}", sp.policy.serial)), r.to_hex().as_bytes(), )?; let (op_ref, op) = self.head_op()?; if op.view.policy != Some(r) { let mut view = op.view.clone(); view.policy = Some(r); self.record_op(&[op_ref], command, view)?; } Ok(r) } /// Every policy this repository has applied, ascending by serial, with /// its signatures. Policies applied before this index existed are /// missing from it, except the current one. pub fn policy_history(&self) -> Result> { let mut out: BTreeMap = BTreeMap::new(); if let Ok(rd) = fs::read_dir(self.oxy.join("policies")) { for e in rd.flatten() { let Ok(hex) = fs::read_to_string(e.path()) else { continue; }; let Some(r) = ObjectRef::from_hex(hex.trim()) else { continue; }; let Ok(bytes) = self.store.get_typed(&r, ObjectType::Policy) else { continue; }; let policy = Policy::decode(&bytes)?; out.insert( policy.serial, SignedPolicy { policy, sigs: self.store.signatures(&r)?, }, ); } } if let Some((_, sp)) = self.current_policy()? { out.insert(sp.policy.serial, sp); } Ok(out.into_values().collect()) } /// Verify and apply a chain of policies ending in `newest`, each /// against the one before it (spec §14.5). `chain` holds earlier /// policies in any order; only those after the current serial are used. pub(crate) fn adopt_policy_chain( &self, chain: &[SignedPolicy], newest: &SignedPolicy, command: &str, ) -> std::result::Result<(), String> { let mut current = self .current_policy() .map_err(|e| e.to_string())? .map(|(_, sp)| sp.policy); let mut steps: Vec<&SignedPolicy> = chain .iter() .filter(|sp| { current.as_ref().is_none_or(|c| sp.policy.serial > c.serial) && sp.policy.serial < newest.policy.serial }) .collect(); steps.sort_by_key(|sp| sp.policy.serial); steps.push(newest); for sp in steps { self.check_policy(sp, current.as_ref())?; self.set_policy( sp, &format!("{command} (policy serial {})", sp.policy.serial), ) .map_err(|e| e.to_string())?; current = Some(sp.policy.clone()); } Ok(()) } /// Validate an incoming policy against the one we trust (`None` when /// bootstrapping: the policy then vouches for itself). pub fn check_policy( &self, sp: &SignedPolicy, trusted: Option<&Policy>, ) -> std::result::Result<(), String> { if sp.policy.repo != self.id().map_err(|e| e.to_string())? { return Err("policy is for a different repository".into()); } if let Some(t) = trusted { if sp.policy.serial <= t.serial { return Err(format!( "policy serial {} is not greater than the current {}", sp.policy.serial, t.serial )); } } match trusted { Some(t) => sp.policy.verify_signatures(&sp.sigs, t)?, None => vec![sp.policy.verify_bootstrap(&sp.sigs)?], }; Ok(()) } /// Sign `policy` with the configured key and apply it if the current /// policy's root threshold is met; otherwise return it as a proposal. fn sign_policy(&self, policy: Policy, command: &str) -> Result { let Some((key, sk)) = self.secret_key()? else { return fail("no secret key: run `oxy identity create` first"); }; self.sign_policy_with(policy, &[(key, &sk)], command) } /// Sign with every listed key; the first must be a root of the current /// policy (the others may be roots of the new policy only, so that a /// fresh clone can bootstrap from it). pub(crate) fn sign_policy_with( &self, policy: Policy, signers: &[(KeyId, &SecretKey)], command: &str, ) -> Result { let trusted = self .current_policy()? .map(|(_, sp)| sp.policy) .unwrap_or_else(|| policy.clone()); if !signers .first() .is_some_and(|(k, _)| trusted.roots.contains(k)) { return fail("your key is not a root key of the current policy"); } let target = policy.hash(); let sigs = signers .iter() .map(|(k, sk)| sk.sign(Domain::Policy, k, &target)) .collect(); self.gather(SignedPolicy { policy, sigs }, &trusted, command) } /// Apply `sp` when its signatures meet `trusted`'s threshold. fn gather(&self, sp: SignedPolicy, trusted: &Policy, command: &str) -> Result { let applied = sp.policy.verify_signatures(&sp.sigs, trusted).is_ok(); if applied { self.set_policy(&sp, command)?; } Ok(PolicyUpdate { policy: sp.policy, sigs: sp.sigs, applied, }) } /// Add your signature to a proposal made by another root holder; the /// policy is applied once the current root threshold is met. pub fn policy_sign(&self, proposal: SignedPolicy) -> Result { let Some((key, sk)) = self.secret_key()? else { return fail("no secret key: run `oxy identity create` first"); }; let Some((_, current)) = self.current_policy()? else { return fail("this repository has no policy; run `oxy policy init` or fetch one"); }; if proposal.policy.repo != self.id()? { return fail("the proposal is for a different repository"); } if proposal.policy.serial <= current.policy.serial { return fail(format!( "the proposal's serial {} is not greater than the current {}", proposal.policy.serial, current.policy.serial )); } if !current.policy.roots.contains(&key) { return fail("your key is not a root key of the current policy"); } let mut sigs: Vec = proposal.sigs.into_iter().filter(|s| s.key != key).collect(); sigs.push(sk.sign(Domain::Policy, &key, &proposal.policy.hash())); self.gather( SignedPolicy { policy: proposal.policy, sigs, }, ¤t.policy, "oxy policy sign", ) } /// Create the initial policy with the configured identity as sole root. pub fn policy_init(&self) -> Result { if self.current_policy()?.is_some() { return fail("this repository already has a policy"); } let Some((key, _)) = self.secret_key()? else { return fail("no secret key: run `oxy identity create` first"); }; let identity = self.identity(&key)?; let policy = Policy::single_root( self.id()?, identity, Hlc::now().wall_ms + POLICY_LIFETIME_MS, ); self.sign_policy(policy, "oxy policy init") } /// Make `identity` a root key. pub fn policy_root_add(&self, identity: Identity) -> Result { let mut p = self.next_policy()?; let key = identity.hash(); if p.roots.contains(&key) { return fail(format!("{} is already a root", key.short(12))); } p.roots.push(key); p.roots.sort(); p.revoked.retain(|k| k != &key); if p.identity(&key).is_none() { p.identities.push(identity); } self.sign_policy(p, &format!("oxy policy root add {}", key.short(12))) } /// Drop a root key; the remaining roots must still meet the threshold. pub fn policy_root_remove(&self, key: &KeyId) -> Result { let mut p = self.next_policy()?; if !p.roots.contains(key) { return fail(format!("{} is not a root", key.short(12))); } p.roots.retain(|k| k != key); if (p.roots.len() as u32) < p.root_threshold { return fail(format!( "removing it would leave {} root(s) for a threshold of {}", p.roots.len(), p.root_threshold )); } self.sign_policy(p, &format!("oxy policy root remove {}", key.short(12))) } /// Require `n` root signatures on future policy updates. pub fn policy_set_threshold(&self, n: u32) -> Result { let mut p = self.next_policy()?; if n == 0 || n as usize > p.roots.len() { return fail(format!( "the threshold must be between 1 and the number of roots ({})", p.roots.len() )); } p.root_threshold = n; self.sign_policy(p, &format!("oxy policy root threshold {n}")) } pub(crate) fn next_policy(&self) -> Result { let Some((_, sp)) = self.current_policy()? else { return fail("this repository has no policy; run `oxy policy init`"); }; let mut p = sp.policy; p.serial += 1; p.expires = Hlc::now().wall_ms + POLICY_LIFETIME_MS; Ok(p) } /// Let `identity` update references matching `pattern` (default every reference). pub fn policy_grant(&self, identity: Identity, pattern: Option<&str>) -> Result { let mut p = self.next_policy()?; let key = identity.hash(); let pattern = pattern.unwrap_or("*"); p.revoked.retain(|k| k != &key); match p.rules.iter_mut().find(|r| r.pattern == pattern) { Some(rule) => { if !rule.keys.contains(&key) { rule.keys.push(key); rule.keys.sort(); } } None => p.rules.push(Rule { pattern: pattern.into(), keys: vec![key], threshold: 1, require: Require::Any, allow_conflicts: true, }), } if p.identity(&key).is_none() { p.identities.push(identity); } self.sign_policy(p, &format!("oxy policy grant {}", key.short(12))) } /// Revoke `key`: it may no longer update anything, and existing rules /// drop it. A root key can be revoked by another root as long as the /// remaining roots still meet the threshold. pub fn policy_revoke(&self, key: &KeyId) -> Result { let mut p = self.next_policy()?; if p.roots.contains(key) { let own = self.secret_key()?.map(|(k, _)| k); if own == Some(*key) { return fail("a root cannot revoke itself; have another root revoke it, or remove it with `oxy policy root remove` first"); } p.roots.retain(|k| k != key); if (p.roots.len() as u32) < p.root_threshold { return fail(format!( "revoking it would leave {} root(s) for a threshold of {}", p.roots.len(), p.root_threshold )); } } for r in &mut p.rules { r.keys.retain(|k| k != key); } p.rules.retain(|r| !r.keys.is_empty()); if !p.revoked.contains(key) { p.revoked.push(*key); p.revoked.sort(); } self.sign_policy(p, &format!("oxy policy revoke {}", key.short(12))) } /// Whether `change` may be part of `reference` under `policy`: it must be /// authenticated (by its author or a delegate) with a key the matching /// rule lists, using the authenticator kind the rule requires. A rule key /// covers its rotation predecessors, and a delegate's signature counts /// for the author it acts for. pub fn change_allowed( &self, hash: &ChangeHash, policy: &Policy, reference: &str, ) -> std::result::Result<(), String> { let rule = policy .rule_for(reference) .ok_or_else(|| format!("no policy rule matches {reference:?}"))?; match self.authenticate_change(hash).map_err(|e| e.to_string())? { SignatureStatus::Valid { key, sealed, .. } => { if policy.is_revoked(&key) { return Err(format!( "change {} is signed by revoked key {}", hash.short(12), key.short(12) )); } let author = self .store .get_change(hash) .map(|c| c.author) .map_err(|e| e.to_string())?; let principal = if author.is_zero() { key } else { author }; let covered = rule .keys .iter() .any(|k| policy.succeeds(k, &key) || policy.succeeds(k, &principal)); if !covered { return Err(format!( "change {} is signed by {}, which may not update {reference:?}", hash.short(12), key.short(12) )); } match (rule.require, sealed) { (Require::Signed, true) => Err(format!( "{reference:?} requires individually signed changes; {} is only sealed", hash.short(12) )), (Require::Sealed, false) => Err(format!( "{reference:?} requires sealed changes; {} is individually signed", hash.short(12) )), _ => Ok(()), } } SignatureStatus::Unsigned => { Err(format!("change {} is not signed or sealed", hash.short(12))) } SignatureStatus::Invalid(e) => Err(format!("change {}: {e}", hash.short(12))), } } /// Check every change that `new` adds over `old` for `reference`. pub(crate) fn state_allowed( &self, old_heads: &[ChangeHash], new_heads: &[ChangeHash], policy: &Policy, reference: &str, ) -> std::result::Result<(), String> { let added = self .negotiate(old_heads, new_heads) .map_err(|e| e.to_string())?; for h in &added { self.change_allowed(h, policy, reference)?; } if let Some(rule) = policy.rule_for(reference) { if !rule.allow_conflicts && !added.is_empty() { let st = self.load_state(new_heads).map_err(|e| e.to_string())?; if st.materialize().is_conflicted() { return Err(format!("{reference:?} does not allow conflicted states")); } } } Ok(()) } }