OXYGENHUB

Trust policy: policy objects, init/grant/revoke, enforcement on push and fetch, propagation with downgrade protection (spec 0.6) 83e0677b07fe

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 19:20 UTC
Signature
✔ signed by Andy McCall
Hash
83e0677b07fe8dc95dd42ab6c00760d520decd6ac3a3761134edfa8bf028b0c3
Depends on
096f447ccda0 1983686fb7a7 2b5ee371060e 5b9c6ad45f6e 750251b21a96 86e682c242c6 982864068a59 fce44ae9ad29 ff0af1ee299d
Depended on by
2f0b4b0651ea c397a8c36a9f 569802ebae0e f3811eb20bea b63fc7a77fc6 1ec6fdbc6bcb d36ba2f79f95 028545dfa7a4 767ac6cd0545 0ed150d62994 4b9d06778581 a2387d547373 f53cbe7a204e f0a517d28237 3a6effc74835 a39754d02c58 cb8b8082fa73 6eae06b6ec04 5ad2b56544c5 7ba155f20aa0 b35df36b7dc5 98a1620a3840 14edbaaf5291 ff1f62fee654 091fef80755d 7eac74abc586 fc1a466a1e66 c863c7a7a2d1 62794ecc7f74 d42369abf180 eae595568832 84b9e9ac0ab4 119bbf50cb86 7aa2dc9369e3 05e71ed6cdf1 75167672ebcd c6aa5bd9d1c8 957fee30826e 4dee277c327a b3c5a59a875a 30ea9c1e70fa 94084087a876 28fc23d7706a 132b884b9a8d 2863456a0f6d 7421309f5372 135212de5370 252e8cb23fbc c3069537d7c5 79ab8528ff62 ba160f60219e d02e545dc265 a269bee11bb2
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
83 atoms, 2 tree operations, 14 files

Browse the tree at this change

added crates/oxy-core/src/policy.rs +310 −0
1 +
//! The trust policy (spec §14.5): who may update which references, signed
2 +
//! by a threshold of root keys. A repository without a policy is unverified.
3 +
//!
4 +
//! Encoding: `{0: version, 1: repo, 2: serial, 3: {0: root keys, 1: threshold},
5 +
//! 4: [rule], 5: [identity bytes], 6: [revoked], 7: expires}`; a rule is
6 +
//! `{0: pattern, 1: keys, 2: threshold, 3: require, 4: allow_conflicts}` with
7 +
//! `require` 0 = signed, 1 = sealed, 2 = any.
8 +
9 +
use crate::cbor::{self, Value};
10 +
use crate::hash::{hash_object, KeyId, ObjectRef, ObjectType};
11 +
use crate::identity::{self, Domain, Identity, Signature};
12 +
use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, ObjectError, FORMAT_VERSION};
13 +
use std::collections::BTreeMap;
14 +
15 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16 +
pub enum Require {
17 +
    Signed,
18 +
    Sealed,
19 +
    Any,
20 +
}
21 +
22 +
impl Require {
23 +
    fn tag(self) -> u64 {
24 +
        match self {
25 +
            Require::Signed => 0,
26 +
            Require::Sealed => 1,
27 +
            Require::Any => 2,
28 +
        }
29 +
    }
30 +
    fn from_tag(t: u64) -> Result<Require, ObjectError> {
31 +
        Ok(match t {
32 +
            0 => Require::Signed,
33 +
            1 => Require::Sealed,
34 +
            2 => Require::Any,
35 +
            _ => return Err(ObjectError::Schema(format!("unknown require {t}"))),
36 +
        })
37 +
    }
38 +
    pub fn name(self) -> &'static str {
39 +
        match self {
40 +
            Require::Signed => "signed",
41 +
            Require::Sealed => "sealed",
42 +
            Require::Any => "any",
43 +
        }
44 +
    }
45 +
}
46 +
47 +
#[derive(Clone, Debug, PartialEq, Eq)]
48 +
pub struct Rule {
49 +
    /// Glob over reference names; `*` matches any run of characters.
50 +
    pub pattern: String,
51 +
    pub keys: Vec<KeyId>,
52 +
    pub threshold: u32,
53 +
    pub require: Require,
54 +
    pub allow_conflicts: bool,
55 +
}
56 +
57 +
#[derive(Clone, Debug, PartialEq, Eq)]
58 +
pub struct Policy {
59 +
    pub repo: [u8; 16],
60 +
    pub serial: u64,
61 +
    pub roots: Vec<KeyId>,
62 +
    pub root_threshold: u32,
63 +
    pub rules: Vec<Rule>,
64 +
    /// Embedded so verification needs no fetch.
65 +
    pub identities: Vec<Identity>,
66 +
    pub revoked: Vec<KeyId>,
67 +
    pub expires: u64,
68 +
}
69 +
70 +
/// `*` matches any run of characters (including `/`); everything else is literal.
71 +
pub fn glob_matches(pattern: &str, name: &str) -> bool {
72 +
    fn rec(p: &[u8], n: &[u8]) -> bool {
73 +
        match p.first() {
74 +
            None => n.is_empty(),
75 +
            Some(b'*') => (0..=n.len()).any(|i| rec(&p[1..], &n[i..])),
76 +
            Some(c) => n.first() == Some(c) && rec(&p[1..], &n[1..]),
77 +
        }
78 +
    }
79 +
    rec(pattern.as_bytes(), name.as_bytes())
80 +
}
81 +
82 +
impl Policy {
83 +
    fn keys_value(keys: &[KeyId]) -> Value {
84 +
        Value::Array(keys.iter().map(|k| Value::Bytes(k.0.to_vec())).collect())
85 +
    }
86 +
    fn keys_from(v: &Value, what: &str) -> Result<Vec<KeyId>, ObjectError> {
87 +
        let mut ks = array(v, what)?.iter().map(|k| oref(k, what)).collect::<Result<Vec<_>, _>>()?;
88 +
        ks.sort();
89 +
        ks.dedup();
90 +
        Ok(ks)
91 +
    }
92 +
93 +
    pub fn to_value(&self) -> Value {
94 +
        Value::map([
95 +
            (0, Value::Uint(FORMAT_VERSION)),
96 +
            (1, Value::Bytes(self.repo.to_vec())),
97 +
            (2, Value::Uint(self.serial)),
98 +
            (3, Value::map([(0, Self::keys_value(&self.roots)), (1, Value::Uint(self.root_threshold as u64))])),
99 +
            (
100 +
                4,
101 +
                Value::Array(
102 +
                    self.rules
103 +
                        .iter()
104 +
                        .map(|r| {
105 +
                            Value::map([
106 +
                                (0, Value::Text(r.pattern.clone())),
107 +
                                (1, Self::keys_value(&r.keys)),
108 +
                                (2, Value::Uint(r.threshold as u64)),
109 +
                                (3, Value::Uint(r.require.tag())),
110 +
                                (4, Value::Bool(r.allow_conflicts)),
111 +
                            ])
112 +
                        })
113 +
                        .collect(),
114 +
                ),
115 +
            ),
116 +
            (5, Value::Array(self.identities.iter().map(|i| Value::Bytes(i.encode())).collect())),
117 +
            (6, Self::keys_value(&self.revoked)),
118 +
            (7, Value::Uint(self.expires)),
119 +
        ])
120 +
    }
121 +
122 +
    pub fn encode(&self) -> Vec<u8> {
123 +
        cbor::encode(&self.to_value())
124 +
    }
125 +
126 +
    pub fn hash(&self) -> ObjectRef {
127 +
        hash_object(ObjectType::Policy, &self.encode())
128 +
    }
129 +
130 +
    pub fn decode(b: &[u8]) -> Result<Policy, ObjectError> {
131 +
        let v = cbor::decode(b)?;
132 +
        let m = map(&v, "policy")?;
133 +
        check_version(m)?;
134 +
        let roots = map(field(m, 3, "roots")?, "roots")?;
135 +
        let mut rules = Vec::new();
136 +
        for r in array(field(m, 4, "rules")?, "rules")? {
137 +
            let rm = map(r, "rule")?;
138 +
            rules.push(Rule {
139 +
                pattern: text(field(rm, 0, "pattern")?, "pattern")?.to_owned(),
140 +
                keys: Self::keys_from(field(rm, 1, "rule keys")?, "rule keys")?,
141 +
                threshold: uint(field(rm, 2, "threshold")?, "threshold")? as u32,
142 +
                require: Require::from_tag(uint(field(rm, 3, "require")?, "require")?)?,
143 +
                allow_conflicts: field(rm, 4, "allow_conflicts")?.as_bool().ok_or_else(|| ObjectError::Schema("allow_conflicts: expected bool".into()))?,
144 +
            });
145 +
        }
146 +
        let identities = array(field(m, 5, "identities")?, "identities")?
147 +
            .iter()
148 +
            .map(|i| Identity::decode(bytes(i, "identity")?))
149 +
            .collect::<Result<Vec<_>, _>>()?;
150 +
        let p = Policy {
151 +
            repo: bytes(field(m, 1, "repo")?, "repo")?.try_into().map_err(|_| ObjectError::Schema("repo: expected 16 bytes".into()))?,
152 +
            serial: uint(field(m, 2, "serial")?, "serial")?,
153 +
            roots: Self::keys_from(field(roots, 0, "root keys")?, "root keys")?,
154 +
            root_threshold: uint(field(roots, 1, "root threshold")?, "root threshold")? as u32,
155 +
            rules,
156 +
            identities,
157 +
            revoked: Self::keys_from(field(m, 6, "revoked")?, "revoked")?,
158 +
            expires: uint(field(m, 7, "expires")?, "expires")?,
159 +
        };
160 +
        if p.root_threshold == 0 || p.root_threshold as usize > p.roots.len() {
161 +
            return Err(ObjectError::Schema("root threshold must be between 1 and the number of root keys".into()));
162 +
        }
163 +
        for r in &p.rules {
164 +
            if r.threshold == 0 || r.threshold as usize > r.keys.len() {
165 +
                return Err(ObjectError::Schema(format!("rule {:?}: threshold must be between 1 and the number of keys", r.pattern)));
166 +
            }
167 +
        }
168 +
        Ok(p)
169 +
    }
170 +
171 +
    pub fn identity(&self, key: &KeyId) -> Option<&Identity> {
172 +
        self.identities.iter().find(|i| i.hash() == *key)
173 +
    }
174 +
175 +
    pub fn is_revoked(&self, key: &KeyId) -> bool {
176 +
        self.revoked.contains(key)
177 +
    }
178 +
179 +
    pub fn is_expired(&self, now_ms: u64) -> bool {
180 +
        now_ms > self.expires
181 +
    }
182 +
183 +
    /// The first rule whose pattern matches `reference`.
184 +
    pub fn rule_for(&self, reference: &str) -> Option<&Rule> {
185 +
        self.rules.iter().find(|r| glob_matches(&r.pattern, reference))
186 +
    }
187 +
188 +
    /// Whether `key` may update `reference`: listed in the matching rule and not revoked.
189 +
    pub fn authorizes(&self, key: &KeyId, reference: &str) -> bool {
190 +
        !self.is_revoked(key) && self.rule_for(reference).is_some_and(|r| r.keys.contains(key))
191 +
    }
192 +
193 +
    /// Verify that `sigs` over this policy's hash meet `trusted`'s root
194 +
    /// threshold (`trusted` is the previous policy, or this one when
195 +
    /// bootstrapping). Returns the signing root keys.
196 +
    pub fn verify_signatures(&self, sigs: &[Signature], trusted: &Policy) -> Result<Vec<KeyId>, String> {
197 +
        let target = self.hash();
198 +
        let mut signers = Vec::new();
199 +
        for sig in sigs {
200 +
            if !trusted.roots.contains(&sig.key) || trusted.is_revoked(&sig.key) {
201 +
                continue;
202 +
            }
203 +
            let Some(id) = trusted.identity(&sig.key).or_else(|| self.identity(&sig.key)) else { continue };
204 +
            if identity::verify(id, sig, Domain::Policy, &target).is_ok() && !signers.contains(&sig.key) {
205 +
                signers.push(sig.key);
206 +
            }
207 +
        }
208 +
        if (signers.len() as u32) < trusted.root_threshold {
209 +
            return Err(format!("policy needs {} root signature(s), has {}", trusted.root_threshold, signers.len()));
210 +
        }
211 +
        Ok(signers)
212 +
    }
213 +
214 +
    /// A single-root policy letting `root` do everything on every reference.
215 +
    pub fn single_root(repo: [u8; 16], root: Identity, expires: u64) -> Policy {
216 +
        let key = root.hash();
217 +
        Policy {
218 +
            repo,
219 +
            serial: 1,
220 +
            roots: vec![key],
221 +
            root_threshold: 1,
222 +
            rules: vec![Rule { pattern: "*".into(), keys: vec![key], threshold: 1, require: Require::Any, allow_conflicts: true }],
223 +
            identities: vec![root],
224 +
            revoked: vec![],
225 +
            expires,
226 +
        }
227 +
    }
228 +
229 +
    /// Identities keyed by KeyId.
230 +
    pub fn identity_map(&self) -> BTreeMap<KeyId, &Identity> {
231 +
        self.identities.iter().map(|i| (i.hash(), i)).collect()
232 +
    }
233 +
}
234 +
235 +
#[cfg(test)]
236 +
mod tests {
237 +
    use super::*;
238 +
    use crate::identity::SecretKey;
239 +
240 +
    fn sk(n: u8) -> SecretKey {
241 +
        SecretKey { ed_seed: [n; 32], ml_seed: [n + 100; 32] }
242 +
    }
243 +
244 +
    #[test]
245 +
    fn globs() {
246 +
        assert!(glob_matches("*", "main"));
247 +
        assert!(glob_matches("release/*", "release/1.0"));
248 +
        assert!(!glob_matches("release/*", "main"));
249 +
        assert!(glob_matches("main", "main"));
250 +
        assert!(!glob_matches("main", "main2"));
251 +
        assert!(glob_matches("*-rc*", "1.0-rc2"));
252 +
        assert!(glob_matches("", ""));
253 +
    }
254 +
255 +
    #[test]
256 +
    fn round_trip_and_rules() {
257 +
        let root = sk(1).identity("Root", "r@x", 0);
258 +
        let dev = sk(2).identity("Dev", "d@x", 0);
259 +
        let mut p = Policy::single_root([9; 16], root.clone(), 10);
260 +
        p.rules.insert(0, Rule { pattern: "release/*".into(), keys: vec![root.hash()], threshold: 1, require: Require::Signed, allow_conflicts: false });
261 +
        p.rules[1].keys.push(dev.hash());
262 +
        p.identities.push(dev.clone());
263 +
        p.revoked.push(ObjectRef([7; 32]));
264 +
        let d = Policy::decode(&p.encode()).unwrap();
265 +
        assert_eq!(d.rules.len(), 2);
266 +
        assert_eq!(d.rules[0].require, Require::Signed);
267 +
        assert_eq!(d.identity(&dev.hash()), Some(&dev));
268 +
        assert!(d.authorizes(&dev.hash(), "main"));
269 +
        assert!(!d.authorizes(&dev.hash(), "release/1"));
270 +
        assert!(d.authorizes(&root.hash(), "release/1"));
271 +
        assert!(d.is_expired(11) && !d.is_expired(10));
272 +
        assert_eq!(d.hash(), p.hash());
273 +
274 +
        let mut bad = p.clone();
275 +
        bad.root_threshold = 5;
276 +
        assert!(Policy::decode(&bad.encode()).is_err());
277 +
    }
278 +
279 +
    #[test]
280 +
    fn signatures_need_root_threshold() {
281 +
        let r1 = sk(1);
282 +
        let r2 = sk(2);
283 +
        let i1 = r1.identity("a", "a", 0);
284 +
        let i2 = r2.identity("b", "b", 0);
285 +
        let mut p = Policy::single_root([1; 16], i1.clone(), 100);
286 +
        p.roots.push(i2.hash());
287 +
        p.roots.sort();
288 +
        p.root_threshold = 2;
289 +
        p.identities.push(i2.clone());
290 +
        let h = p.hash();
291 +
        let s1 = r1.sign(Domain::Policy, &i1.hash(), &h);
292 +
        let s2 = r2.sign(Domain::Policy, &i2.hash(), &h);
293 +
        assert!(p.verify_signatures(&[s1.clone()], &p).is_err());
294 +
        assert_eq!(p.verify_signatures(&[s1.clone(), s2.clone()], &p).unwrap().len(), 2);
295 +
        // Duplicates do not count twice; a non-root signer does not count.
296 +
        assert!(p.verify_signatures(&[s1.clone(), s1.clone()], &p).is_err());
297 +
        let stranger = sk(3);
298 +
        let si = stranger.identity("s", "s", 0);
299 +
        let s3 = stranger.sign(Domain::Policy, &si.hash(), &h);
300 +
        assert!(p.verify_signatures(&[s1.clone(), s3], &p).is_err());
301 +
        // A successor policy is verified against the previous roots.
302 +
        let mut next = p.clone();
303 +
        next.serial = 2;
304 +
        next.revoked.push(i2.hash());
305 +
        let nh = next.hash();
306 +
        let n1 = r1.sign(Domain::Policy, &i1.hash(), &nh);
307 +
        let n2 = r2.sign(Domain::Policy, &i2.hash(), &nh);
308 +
        assert_eq!(next.verify_signatures(&[n1, n2], &p).unwrap().len(), 2);
309 +
    }
310 +
}
added crates/oxy-core/src/repo/policy.rs +177 −0
1 +
//! Trust policy management and enforcement (spec §14.5).
2 +
//!
3 +
//! A repository with a policy verifies everything it receives: every change
4 +
//! must be authenticated by a key the policy authorizes for the bookmark,
5 +
//! every bookmark update must carry an attestation by such a key, and a new
6 +
//! policy must be signed by a threshold of the roots of the policy it replaces.
7 +
8 +
use super::*;
9 +
use crate::policy::{Policy, Require, Rule};
10 +
use crate::protocol::SignedPolicy;
11 +
12 +
/// One year, the default policy lifetime.
13 +
const POLICY_LIFETIME_MS: u64 = 365 * 24 * 60 * 60 * 1000;
14 +
15 +
impl Repo {
16 +
    /// The current policy and its signatures, if the repository has one.
17 +
    pub fn current_policy(&self) -> Result<Option<(ObjectRef, SignedPolicy)>> {
18 +
        let Some(r) = self.view()?.policy else { return Ok(None) };
19 +
        let policy = Policy::decode(&self.store.get_typed(&r, ObjectType::Policy)?)?;
20 +
        Ok(Some((r, SignedPolicy { policy, sigs: self.store.signatures(&r)? })))
21 +
    }
22 +
23 +
    /// Store a policy with its signatures and point the `policy` reference at it.
24 +
    pub(crate) fn set_policy(&self, sp: &SignedPolicy, command: &str) -> Result<ObjectRef> {
25 +
        let r = self.store.put(ObjectType::Policy, &sp.policy.encode())?;
26 +
        for sig in &sp.sigs {
27 +
            self.store.put_signature(&r, sig)?;
28 +
        }
29 +
        for id in &sp.policy.identities {
30 +
            self.store.put_identity(id)?;
31 +
        }
32 +
        let (op_ref, op) = self.head_op()?;
33 +
        if op.view.policy != Some(r) {
34 +
            let mut view = op.view.clone();
35 +
            view.policy = Some(r);
36 +
            self.record_op(&[op_ref], command, view)?;
37 +
        }
38 +
        Ok(r)
39 +
    }
40 +
41 +
    /// Validate an incoming policy against the one we trust (`None` when
42 +
    /// bootstrapping: the policy then vouches for itself).
43 +
    pub fn check_policy(&self, sp: &SignedPolicy, trusted: Option<&Policy>) -> std::result::Result<(), String> {
44 +
        if sp.policy.repo != self.id().map_err(|e| e.to_string())? {
45 +
            return Err("policy is for a different repository".into());
46 +
        }
47 +
        if let Some(t) = trusted {
48 +
            if sp.policy.serial <= t.serial {
49 +
                return Err(format!("policy serial {} is not greater than the current {}", sp.policy.serial, t.serial));
50 +
            }
51 +
        }
52 +
        sp.policy.verify_signatures(&sp.sigs, trusted.unwrap_or(&sp.policy))?;
53 +
        Ok(())
54 +
    }
55 +
56 +
    fn sign_policy(&self, policy: Policy) -> Result<SignedPolicy> {
57 +
        let Some((key, sk)) = self.secret_key()? else {
58 +
            return fail("no secret key: run `oxy identity create` first");
59 +
        };
60 +
        if !policy.roots.contains(&key) {
61 +
            return fail("your key is not a root key of this policy");
62 +
        }
63 +
        if policy.root_threshold > 1 {
64 +
            return fail(format!("this policy needs {} root signatures; multi-signer updates are not implemented yet", policy.root_threshold));
65 +
        }
66 +
        let sig = sk.sign(Domain::Policy, &key, &policy.hash());
67 +
        Ok(SignedPolicy { policy, sigs: vec![sig] })
68 +
    }
69 +
70 +
    /// Create the initial policy with the configured identity as sole root.
71 +
    pub fn policy_init(&self) -> Result<SignedPolicy> {
72 +
        if self.current_policy()?.is_some() {
73 +
            return fail("this repository already has a policy");
74 +
        }
75 +
        let Some((key, _)) = self.secret_key()? else {
76 +
            return fail("no secret key: run `oxy identity create` first");
77 +
        };
78 +
        let identity = self.identity(&key)?;
79 +
        let policy = Policy::single_root(self.id()?, identity, Hlc::now().wall_ms + POLICY_LIFETIME_MS);
80 +
        let sp = self.sign_policy(policy)?;
81 +
        self.set_policy(&sp, "oxy policy init")?;
82 +
        Ok(sp)
83 +
    }
84 +
85 +
    fn next_policy(&self) -> Result<Policy> {
86 +
        let Some((_, sp)) = self.current_policy()? else {
87 +
            return fail("this repository has no policy; run `oxy policy init`");
88 +
        };
89 +
        let mut p = sp.policy;
90 +
        p.serial += 1;
91 +
        p.expires = Hlc::now().wall_ms + POLICY_LIFETIME_MS;
92 +
        Ok(p)
93 +
    }
94 +
95 +
    /// Let `identity` update references matching `pattern` (default every reference).
96 +
    pub fn policy_grant(&self, identity: Identity, pattern: Option<&str>) -> Result<SignedPolicy> {
97 +
        let mut p = self.next_policy()?;
98 +
        let key = identity.hash();
99 +
        let pattern = pattern.unwrap_or("*");
100 +
        p.revoked.retain(|k| k != &key);
101 +
        match p.rules.iter_mut().find(|r| r.pattern == pattern) {
102 +
            Some(rule) => {
103 +
                if !rule.keys.contains(&key) {
104 +
                    rule.keys.push(key);
105 +
                    rule.keys.sort();
106 +
                }
107 +
            }
108 +
            None => p.rules.push(Rule { pattern: pattern.into(), keys: vec![key], threshold: 1, require: Require::Any, allow_conflicts: true }),
109 +
        }
110 +
        if p.identity(&key).is_none() {
111 +
            p.identities.push(identity);
112 +
        }
113 +
        let sp = self.sign_policy(p)?;
114 +
        self.set_policy(&sp, &format!("oxy policy grant {}", key.short(12)))?;
115 +
        Ok(sp)
116 +
    }
117 +
118 +
    /// Revoke `key`: it may no longer update anything, and existing rules drop it.
119 +
    pub fn policy_revoke(&self, key: &KeyId) -> Result<SignedPolicy> {
120 +
        let mut p = self.next_policy()?;
121 +
        if p.roots.contains(key) {
122 +
            return fail("cannot revoke a root key");
123 +
        }
124 +
        for r in &mut p.rules {
125 +
            r.keys.retain(|k| k != key);
126 +
        }
127 +
        p.rules.retain(|r| !r.keys.is_empty());
128 +
        if !p.revoked.contains(key) {
129 +
            p.revoked.push(*key);
130 +
            p.revoked.sort();
131 +
        }
132 +
        let sp = self.sign_policy(p)?;
133 +
        self.set_policy(&sp, &format!("oxy policy revoke {}", key.short(12)))?;
134 +
        Ok(sp)
135 +
    }
136 +
137 +
    /// Whether `change` may be part of `reference` under `policy`: it must be
138 +
    /// authenticated (by its author or a delegate) with a key the matching
139 +
    /// rule lists, using the authenticator kind the rule requires.
140 +
    pub fn change_allowed(&self, hash: &ChangeHash, policy: &Policy, reference: &str) -> std::result::Result<(), String> {
141 +
        let rule = policy.rule_for(reference).ok_or_else(|| format!("no policy rule matches {reference:?}"))?;
142 +
        match self.authenticate_change(hash).map_err(|e| e.to_string())? {
143 +
            SignatureStatus::Valid { key, sealed, .. } => {
144 +
                if policy.is_revoked(&key) {
145 +
                    return Err(format!("change {} is signed by revoked key {}", hash.short(12), key.short(12)));
146 +
                }
147 +
                if !rule.keys.contains(&key) {
148 +
                    return Err(format!("change {} is signed by {}, which may not update {reference:?}", hash.short(12), key.short(12)));
149 +
                }
150 +
                match (rule.require, sealed) {
151 +
                    (Require::Signed, true) => Err(format!("{reference:?} requires individually signed changes; {} is only sealed", hash.short(12))),
152 +
                    (Require::Sealed, false) => Err(format!("{reference:?} requires sealed changes; {} is individually signed", hash.short(12))),
153 +
                    _ => Ok(()),
154 +
                }
155 +
            }
156 +
            SignatureStatus::Unsigned => Err(format!("change {} is not signed or sealed", hash.short(12))),
157 +
            SignatureStatus::Invalid(e) => Err(format!("change {}: {e}", hash.short(12))),
158 +
        }
159 +
    }
160 +
161 +
    /// Check every change that `new` adds over `old` for `reference`.
162 +
    pub(crate) fn state_allowed(&self, old_heads: &[ChangeHash], new_heads: &[ChangeHash], policy: &Policy, reference: &str) -> std::result::Result<(), String> {
163 +
        let added = self.negotiate(old_heads, new_heads).map_err(|e| e.to_string())?;
164 +
        for h in &added {
165 +
            self.change_allowed(h, policy, reference)?;
166 +
        }
167 +
        if let Some(rule) = policy.rule_for(reference) {
168 +
            if !rule.allow_conflicts && !added.is_empty() {
169 +
                let st = self.load_state(new_heads).map_err(|e| e.to_string())?;
170 +
                if st.materialize().is_conflicted() {
171 +
                    return Err(format!("{reference:?} does not allow conflicted states"));
172 +
                }
173 +
            }
174 +
        }
175 +
        Ok(())
176 +
    }
177 +
}
modified crates/oxy-core/src/identity.rs +2 −0
…
89 unchanged lines
90 90
pub enum Domain {
91 91
    Change,
92 92
    Attest,
93 +
    Policy,
93 94
}
94 95
95 96
impl Domain {
…
1 unchanged lines
97 98
        match self {
98 99
            Domain::Change => "change",
99 100
            Domain::Attest => "attest",
101 +
            Domain::Policy => "policy",
100 102
        }
101 103
    }
102 104
}
…
241 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
11 unchanged lines
12 12
pub mod ids;
13 13
pub mod objects;
14 14
pub mod oplog;
15 +
pub mod policy;
15 16
pub mod protocol;
16 17
pub mod repo;
17 18
pub mod state;
…
2 unchanged lines
modified crates/oxy-core/src/oplog.rs +7 −0
…
19 unchanged lines
20 20
    pub workspaces: BTreeMap<WorkspaceId, ChangeHash>,
21 21
    /// `remotes/<remote>/bookmarks/<name>`: remote → bookmark → state ObjectRef.
22 22
    pub remotes: BTreeMap<String, BTreeMap<String, ObjectRef>>,
23 +
    /// The `policy` reference (spec §10.1, §14.5): ObjectRef of the current policy.
24 +
    pub policy: Option<ObjectRef>,
23 25
}
24 26
25 27
/// A format-1 view, only produced by `Op::decode_format1`.
…
48 unchanged lines
74 76
                        .collect(),
75 77
                ),
76 78
            ),
79 +
            (3, self.policy.map(|p| Value::Bytes(p.0.to_vec())).unwrap_or(Value::Null)),
77 80
        ])
78 81
    }
79 82
…
27 unchanged lines
107 110
                view.remotes.insert(name, bms);
108 111
            }
109 112
        }
113 +
        if let Some(p) = m.get(&3) {
114 +
            view.policy = if p.is_null() { None } else { Some(oref(p, "policy")?) };
115 +
        }
110 116
        Ok(view)
111 117
    }
112 118
}
…
146 unchanged lines
259 265
        view.bookmarks.insert("dev".into(), ObjectRef([3; 32]));
260 266
        view.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32]));
261 267
        view.remotes.entry("origin".into()).or_default().insert("main".into(), ObjectRef([4; 32]));
268 +
        view.policy = Some(ObjectRef([6; 32]));
262 269
        let op = Op { parents: vec![ObjectRef([7; 32])], hlc: Hlc { wall_ms: 5, logical: 1 }, command: "oxy commit".into(), view };
263 270
        let d = Op::decode(&op.encode()).unwrap();
264 271
        assert_eq!(d, op);
…
11 unchanged lines
modified crates/oxy-core/src/protocol.rs +70 −20
…
10 unchanged lines
11 11
use crate::hash::{ChangeHash, ObjectRef, ObjectType};
12 12
use crate::identity::Signature;
13 13
use crate::objects::{self as o, Change, ObjectError};
14 +
use crate::policy::Policy;
14 15
use crate::repo::{Repo, RepoError, Result};
15 16
use std::path::{Path, PathBuf};
16 17
use std::sync::Arc;
…
8 unchanged lines
25 26
    pub sigs: Vec<Signature>,
26 27
}
27 28
29 +
/// A policy together with the root signatures over its ObjectRef.
30 +
#[derive(Clone, Debug, PartialEq, Eq)]
31 +
pub struct SignedPolicy {
32 +
    pub policy: Policy,
33 +
    pub sigs: Vec<Signature>,
34 +
}
35 +
36 +
/// What `refs` returns: every bookmark plus the current policy, if any.
37 +
#[derive(Clone, Debug, Default)]
38 +
pub struct RefsResponse {
39 +
    pub refs: Vec<RemoteRef>,
40 +
    pub policy: Option<SignedPolicy>,
41 +
}
42 +
28 43
/// One reference as a remote reports it.
29 44
#[derive(Clone, Debug)]
30 45
pub struct RemoteRef {
…
39 unchanged lines
70 85
71 86
pub trait Remote {
72 87
    fn repo_id(&self) -> Result<[u8; 16]>;
73 -
    fn refs(&self) -> Result<Vec<RemoteRef>>;
88 +
    fn refs(&self) -> Result<RefsResponse>;
74 89
    /// Changes in the closure of `want` but not of `have`, in dependency order.
75 90
    fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>>;
76 91
    fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle>;
77 -
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>) -> Result<Vec<RefResult>>;
92 +
    /// Push changes, bookmark updates, and optionally a newer policy. The
93 +
    /// policy is applied first; a result named `policy` reports it.
94 +
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>>;
78 95
}
79 96
80 97
// ---------------------------------------------------------------- local
…
13 unchanged lines
94 111
    fn repo_id(&self) -> Result<[u8; 16]> {
95 112
        self.repo.id()
96 113
    }
97 -
    fn refs(&self) -> Result<Vec<RemoteRef>> {
114 +
    fn refs(&self) -> Result<RefsResponse> {
98 115
        self.repo.remote_refs()
99 116
    }
100 117
    fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
…
2 unchanged lines
103 120
    fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle> {
104 121
        self.repo.bundle_for(changes)
105 122
    }
106 -
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>) -> Result<Vec<RefResult>> {
107 -
        self.repo.receive_push(bundle, updates)
123 +
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> {
124 +
        self.repo.receive_push(bundle, updates, policy)
108 125
    }
109 126
}
110 127
…
76 unchanged lines
187 204
    fn repo_id(&self) -> Result<[u8; 16]> {
188 205
        wire::decode_capabilities(&self.call(false, "capabilities", None)?)
189 206
    }
190 -
    fn refs(&self) -> Result<Vec<RemoteRef>> {
207 +
    fn refs(&self) -> Result<RefsResponse> {
191 208
        Ok(wire::decode_refs(&self.call(false, "refs", None)?)?.1)
192 209
    }
193 210
    fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
…
2 unchanged lines
196 213
    fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle> {
197 214
        wire::decode_bundle(&self.call(true, "fetch", Some(&wire::encode_hashes(changes)))?)
198 215
    }
199 -
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>) -> Result<Vec<RefResult>> {
200 -
        wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates)))?)
216 +
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> {
217 +
        wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates, policy.as_ref())))?)
201 218
    }
202 219
}
203 220
…
46 unchanged lines
250 267
    fn opt_attest_from(v: &Value) -> Result<Option<SignedAttest>> {
251 268
        if v.is_null() { Ok(None) } else { attest_from(v).map(Some) }
252 269
    }
270 +
    fn opt_policy_value(sp: Option<&SignedPolicy>) -> Value {
271 +
        match sp {
272 +
            Some(sp) => Value::Array(vec![Value::Bytes(sp.policy.encode()), sigs_value(&sp.sigs)]),
273 +
            None => Value::Null,
274 +
        }
275 +
    }
276 +
    fn opt_policy_from(v: &Value) -> Result<Option<SignedPolicy>> {
277 +
        if v.is_null() {
278 +
            return Ok(None);
279 +
        }
280 +
        let a = o::array(v, "signed policy")?;
281 +
        if a.len() != 2 {
282 +
            return Err(schema("signed policy: expected [policy, sigs]"));
283 +
        }
284 +
        Ok(Some(SignedPolicy { policy: Policy::decode(o::bytes(&a[0], "policy")?)?, sigs: sigs_from(&a[1])? }))
285 +
    }
253 286
    fn map<'a>(v: &'a Value, what: &str) -> Result<&'a o::Map> {
254 287
        Ok(o::map(v, what)?)
255 288
    }
…
65 unchanged lines
321 354
        o::bytes(field(m, 1, "repo id")?, "repo id")?.try_into().map_err(|_| schema("repo id: expected 16 bytes"))
322 355
    }
323 356
324 -
    pub fn encode_refs(id: [u8; 16], refs: &[RemoteRef]) -> Vec<u8> {
357 +
    pub fn encode_refs(id: [u8; 16], resp: &RefsResponse) -> Vec<u8> {
325 358
        cbor::encode(&Value::map([
326 359
            (
327 360
                0,
328 361
                Value::Array(
329 -
                    refs.iter()
362 +
                    resp.refs
363 +
                        .iter()
330 364
                        .map(|r| {
331 365
                            Value::map([
332 366
                                (0, Value::Text(r.name.clone())),
…
7 unchanged lines
340 374
                ),
341 375
            ),
342 376
            (1, Value::Bytes(id.to_vec())),
377 +
            (2, opt_policy_value(resp.policy.as_ref())),
343 378
        ]))
344 379
    }
345 -
    pub fn decode_refs(bytes: &[u8]) -> Result<([u8; 16], Vec<RemoteRef>)> {
380 +
    pub fn decode_refs(bytes: &[u8]) -> Result<([u8; 16], RefsResponse)> {
346 381
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
347 382
        let m = map(&v, "refs")?;
348 383
        let id: [u8; 16] = o::bytes(field(m, 1, "repo id")?, "repo id")?.try_into().map_err(|_| schema("repo id: expected 16 bytes"))?;
…
9 unchanged lines
358 393
                last_serial: if ls.is_null() { None } else { Some(o::uint(ls, "last serial")?) },
359 394
            });
360 395
        }
361 -
        Ok((id, refs))
396 +
        let policy = match m.get(&2) {
397 +
            Some(p) => opt_policy_from(p)?,
398 +
            None => None,
399 +
        };
400 +
        Ok((id, RefsResponse { refs, policy }))
362 401
    }
363 402
364 403
    pub fn encode_negotiate(have: &[ChangeHash], want: &[ChangeHash]) -> Vec<u8> {
…
13 unchanged lines
378 417
        hashes_from(field(map(&v, "hashes")?, 0, "hashes")?)
379 418
    }
380 419
381 -
    pub fn encode_push(bundle: &Bundle, updates: &[RefUpdate]) -> Vec<u8> {
420 +
    pub fn encode_push(bundle: &Bundle, updates: &[RefUpdate], policy: Option<&SignedPolicy>) -> Vec<u8> {
382 421
        cbor::encode(&Value::map([
383 422
            (0, bundle_value(bundle)),
423 +
            (2, opt_policy_value(policy)),
384 424
            (
385 425
                1,
386 426
                Value::Array(
…
13 unchanged lines
400 440
            ),
401 441
        ]))
402 442
    }
403 -
    pub fn decode_push(bytes: &[u8]) -> Result<(Bundle, Vec<RefUpdate>)> {
443 +
    pub fn decode_push(bytes: &[u8]) -> Result<(Bundle, Vec<RefUpdate>, Option<SignedPolicy>)> {
404 444
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
405 445
        let m = map(&v, "push")?;
406 446
        let bundle = bundle_from_value(field(m, 0, "bundle")?)?;
…
8 unchanged lines
415 455
                attestation: opt_attest_from(field(um, 4, "attestation")?)?,
416 456
            });
417 457
        }
418 -
        Ok((bundle, updates))
458 +
        let policy = match m.get(&2) {
459 +
            Some(p) => opt_policy_from(p)?,
460 +
            None => None,
461 +
        };
462 +
        Ok((bundle, updates, policy))
419 463
    }
420 464
421 465
    pub fn encode_push_response(results: &[RefResult]) -> Vec<u8> {
…
53 unchanged lines
475 519
            RemoteRef { name: "main".into(), state: ObjectRef([2; 32]), heads: vec![c.hash()], attestation: Some(SignedAttest { attest: att.clone(), sigs: vec![sig.clone()] }), last_serial: Some(4) },
476 520
            RemoteRef { name: "dev".into(), state: ObjectRef([5; 32]), heads: vec![], attestation: None, last_serial: None },
477 521
        ];
478 -
        let (id_back, back) = wire::decode_refs(&wire::encode_refs([3; 16], &refs)).unwrap();
522 +
        let policy = Policy::single_root([3; 16], id.clone(), 99);
523 +
        let psig = sk.sign(crate::identity::Domain::Policy, &key, &policy.hash());
524 +
        let resp = RefsResponse { refs, policy: Some(SignedPolicy { policy: policy.clone(), sigs: vec![psig.clone()] }) };
525 +
        let (id_back, back) = wire::decode_refs(&wire::encode_refs([3; 16], &resp)).unwrap();
479 526
        assert_eq!(id_back, [3; 16]);
480 -
        assert_eq!(back.len(), 2);
481 -
        assert_eq!(back[0].attestation.as_ref().unwrap().attest, att);
482 -
        assert_eq!(back[1].last_serial, None);
527 +
        assert_eq!(back.refs.len(), 2);
528 +
        assert_eq!(back.refs[0].attestation.as_ref().unwrap().attest, att);
529 +
        assert_eq!(back.refs[1].last_serial, None);
530 +
        assert_eq!(back.policy.as_ref().unwrap().policy, policy);
483 531
484 532
        let (h, w) = wire::decode_negotiate(&wire::encode_negotiate(&[ObjectRef([1; 32])], &[ObjectRef([2; 32])])).unwrap();
485 533
        assert_eq!((h, w), (vec![ObjectRef([1; 32])], vec![ObjectRef([2; 32])]));
486 534
        assert_eq!(wire::decode_hashes(&wire::encode_hashes(&[c.hash()])).unwrap(), vec![c.hash()]);
487 535
488 536
        let up = RefUpdate { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), force: true, attestation: Some(SignedAttest { attest: att, sigs: vec![sig] }) };
489 -
        let (b2, ups) = wire::decode_push(&wire::encode_push(&bundle, std::slice::from_ref(&up))).unwrap();
537 +
        let sp = SignedPolicy { policy, sigs: vec![psig] };
538 +
        let (b2, ups, pol) = wire::decode_push(&wire::encode_push(&bundle, std::slice::from_ref(&up), Some(&sp))).unwrap();
539 +
        assert_eq!(pol, Some(sp));
490 540
        assert_eq!(b2.changes.len(), 1);
491 541
        assert_eq!(ups[0].name, "main");
492 542
        assert!(ups[0].force);
…
6 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +7 −5
1 1
//! A repository on disk: the `.oxy` directory, one workspace, the operation
2 2
//! log, and the working-copy change (spec §12, §13.1).
3 3
//!
4 -
//! Not yet implemented from the spec: trust policy (every remote is
5 -
//! "unverified", so unsigned pushes are accepted), packs, checkpoints,
6 -
//! multiple workspaces, and network transports (only local paths). Changes are signed individually when `core.sign` is
4 +
//! Not yet implemented from the spec: packs, checkpoints, multiple
5 +
//! workspaces, multi-signer policy updates, and the `objects`/`checkpoint`
6 +
//! endpoints. A repository without a policy is unverified and accepts
7 +
//! unsigned pushes; with one, everything received is checked (spec §14.5). Changes are signed individually when `core.sign` is
7 8
//! `change`; the default `seal` leaves them unsigned until push exists.
8 9
9 10
use crate::attest::{Attest, AttestBody};
10 11
use crate::diff::{ranges, split_lines, text_edits};
11 -
use crate::protocol::{open_remote, Bundle, RefResult, RefUpdate, RemoteOptions, RemoteRef, SignedAttest};
12 +
use crate::protocol::{open_remote, Bundle, RefResult, RefUpdate, RefsResponse, RemoteOptions, RemoteRef, SignedAttest, SignedPolicy};
12 13
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
13 14
use crate::identity::{verify as verify_signature, Domain, Identity, SecretKey, Signature};
14 15
use crate::ids::{ChangeId, NodeId, WorkspaceId};
…
36 unchanged lines
51 52
mod checkout;
52 53
mod config;
53 54
mod identity;
55 +
mod policy;
54 56
mod snapshot;
55 57
mod sync;
56 58
#[cfg(test)]
…
3 unchanged lines
60 62
pub use config::{read_config_file, Config};
61 63
pub use identity::SignatureStatus;
62 64
pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult};
63 -
pub use sync::PullReport;
65 +
pub use sync::{FetchReport, PullReport};
64 66
65 67
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
66 68
    let tmp = path.with_extension("tmp");
…
491 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +149 −16
…
1 unchanged lines
2 2
3 3
use super::*;
4 4
5 +
/// What a fetch brought in.
6 +
#[derive(Debug, Clone, PartialEq, Eq, Default)]
7 +
pub struct FetchReport {
8 +
    /// Bookmark name, its heads, and how many changes were new.
9 +
    pub refs: Vec<(String, Vec<ChangeHash>, usize)>,
10 +
    /// The remote has never presented a policy, so nothing was verified
11 +
    /// against one (spec §14.5: warn loudly).
12 +
    pub unverified: bool,
13 +
}
14 +
5 15
#[derive(Debug, Clone, PartialEq, Eq)]
6 16
pub struct PullReport {
7 17
    pub heads: Vec<ChangeHash>,
…
25 unchanged lines
33 43
        write_atomic(&self.ref_attest_path(bookmark), r.to_hex().as_bytes())
34 44
    }
35 45
36 -
    pub fn remote_refs(&self) -> Result<Vec<RemoteRef>> {
46 +
    pub fn remote_refs(&self) -> Result<RefsResponse> {
37 47
        let view = self.view()?;
38 48
        let mut out = Vec::new();
39 49
        for (name, state) in &view.bookmarks {
…
5 unchanged lines
45 55
            let attestation = latest.filter(|a| matches!(&a.attest.body, AttestBody::Ref { new_state, .. } if new_state == state));
46 56
            out.push(RemoteRef { name: name.clone(), state: *state, heads: self.store.get_state(state)?.heads, attestation, last_serial });
47 57
        }
48 -
        Ok(out)
58 +
        Ok(RefsResponse { refs: out, policy: self.current_policy()?.map(|(_, sp)| sp) })
49 59
    }
50 60
51 61
    /// Every change reachable from `heads` that is in the store.
…
161 unchanged lines
213 223
        Ok(*serial)
214 224
    }
215 225
216 -
    /// Accept a push (spec §16.3). Without a trust policy every repository
226 +
    /// Accept a push (spec §16.4). Without a trust policy every repository
217 227
    /// is unverified, so unsigned changes and unattested updates are accepted;
218 -
    /// attestations that are present must still be valid.
219 -
    pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>) -> Result<Vec<RefResult>> {
220 -
        self.import_bundle(&bundle, false)?;
228 +
    /// attestations that are present must still be valid. With a policy,
229 +
    /// every change and update is checked against it. A newer policy in the
230 +
    /// push is applied first when it is signed by the current roots.
231 +
    pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> {
232 +
        let mut results = Vec::new();
233 +
        if let Some(sp) = policy {
234 +
            let current = self.current_policy()?.map(|(_, c)| c.policy);
235 +
            match self.check_policy(&sp, current.as_ref()) {
236 +
                Ok(()) => {
237 +
                    self.set_policy(&sp, &format!("push received: policy serial {}", sp.policy.serial))?;
238 +
                    results.push(RefResult { name: "policy".into(), ok: true, reason: format!("serial {}", sp.policy.serial) });
239 +
                }
240 +
                Err(e) => {
241 +
                    results.push(RefResult { name: "policy".into(), ok: false, reason: e });
242 +
                    for u in updates {
243 +
                        results.push(RefResult { name: u.name, ok: false, reason: "policy update was rejected".into() });
244 +
                    }
245 +
                    return Ok(results);
246 +
                }
247 +
            }
248 +
        }
249 +
        let policy = self.current_policy()?.map(|(_, sp)| sp.policy);
250 +
        if let Err(e) = self.import_bundle(&bundle, policy.is_some()) {
251 +
            for u in updates {
252 +
                results.push(RefResult { name: u.name, ok: false, reason: e.to_string() });
253 +
            }
254 +
            return Ok(results);
255 +
        }
221 256
        let (op_ref, op) = self.head_op()?;
222 257
        let mut view = op.view.clone();
223 -
        let mut results = Vec::new();
224 258
        let mut names = Vec::new();
225 259
        for u in updates {
226 -
            let outcome = self.apply_update(&mut view, &u);
260 +
            let outcome = self.apply_update(&mut view, &u, policy.as_ref());
227 261
            let (ok, reason) = match outcome {
228 262
                Ok(()) => (true, String::new()),
229 263
                Err(e) => (false, e),
…
9 unchanged lines
239 273
        Ok(results)
240 274
    }
241 275
242 -
    fn apply_update(&self, view: &mut View, u: &RefUpdate) -> std::result::Result<(), String> {
276 +
    fn apply_update(&self, view: &mut View, u: &RefUpdate, policy: Option<&crate::policy::Policy>) -> std::result::Result<(), String> {
277 +
        if u.name == "policy" {
278 +
            return Err("the policy reference is updated by sending a policy, not a bookmark update".into());
279 +
        }
243 280
        let empty = self.store.put_state(&StateObject::new(vec![])).map_err(|e| e.to_string())?;
244 281
        let current = view.bookmarks.get(&u.name).copied().unwrap_or(empty);
245 282
        if current != u.old_state {
…
12 unchanged lines
258 295
                return Err(format!("not a fast-forward: current head {} would be dropped (use --force)", h.short(12)));
259 296
            }
260 297
        }
298 +
        if let Some(p) = policy {
299 +
            let Some(sa) = &u.attestation else {
300 +
                return Err(format!("{:?} requires a signed reference attestation under this repository's policy", u.name));
301 +
            };
302 +
            let signer = sa.sigs.first().map(|s| s.key).ok_or("attestation carries no signature")?;
303 +
            if !p.authorizes(&signer, &u.name) {
304 +
                return Err(format!("key {} may not update {:?} under this repository's policy", signer.short(12), u.name));
305 +
            }
306 +
            let old_heads = self.store.get_state(&current).map_err(|e| e.to_string())?.heads;
307 +
            self.state_allowed(&old_heads, &new_state.heads, p, &u.name)?;
308 +
        }
261 309
        if let Some(sa) = &u.attestation {
262 310
            let last = self.latest_ref_attest(&u.name).map_err(|e| e.to_string())?.and_then(|(_, a)| match a.attest.body {
263 311
                AttestBody::Ref { serial, .. } => Some(serial),
…
43 unchanged lines
307 355
        write_atomic(&p, serial.to_string().as_bytes())
308 356
    }
309 357
358 +
    fn policy_serial_path(&self, remote: &str) -> PathBuf {
359 +
        self.oxy.join("remotes").join(remote).join("policy.serial")
360 +
    }
361 +
362 +
    /// The highest policy serial `remote` has ever presented.
363 +
    fn remote_policy_serial(&self, remote: &str) -> Option<u64> {
364 +
        fs::read_to_string(self.policy_serial_path(remote)).ok().and_then(|s| s.trim().parse().ok())
365 +
    }
366 +
367 +
    fn record_policy_serial(&self, remote: &str, serial: u64) -> Result<()> {
368 +
        let p = self.policy_serial_path(remote);
369 +
        fs::create_dir_all(p.parent().unwrap())?;
370 +
        write_atomic(&p, serial.to_string().as_bytes())
371 +
    }
372 +
310 373
    /// Every head this repository knows: bookmarks, remote tracking, working copy.
311 374
    fn known_heads(&self, view: &View) -> Result<Vec<ChangeHash>> {
312 375
        let mut have: BTreeSet<ChangeHash> = BTreeSet::new();
…
7 unchanged lines
320 383
    }
321 384
322 385
    /// Fetch every bookmark of a remote into `remotes/<name>/bookmarks/*` (spec §10.2).
323 -
    pub fn fetch(&self, remote_name: &str) -> Result<Vec<(String, Vec<ChangeHash>, usize)>> {
386 +
    pub fn fetch(&self, remote_name: &str) -> Result<FetchReport> {
324 387
        let remote = self.open_named_remote(remote_name)?;
325 388
        if remote.repo_id()? != self.id()? {
326 389
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
327 390
        }
391 +
        let resp = remote.refs()?;
392 +
        // Trust policy (spec §14.5). Adopt the remote's policy when it is newer
393 +
        // and signed by roots we trust, or on first contact. A remote that
394 +
        // once presented a policy may never present none or an older one:
395 +
        // that is a downgrade. A remote that never presented one is
396 +
        // unverified, and this fetch is checked only as far as possible.
397 +
        let local_policy = self.current_policy()?.map(|(_, sp)| sp.policy);
398 +
        let seen = self.remote_policy_serial(remote_name);
399 +
        let mut unverified = false;
400 +
        match &resp.policy {
401 +
            Some(sp) => {
402 +
                if seen.is_some_and(|s| sp.policy.serial < s) {
403 +
                    return fail(format!("{remote_name} presents policy serial {} but presented {} before: refusing the downgrade", sp.policy.serial, seen.unwrap()));
404 +
                }
405 +
                match &local_policy {
406 +
                    Some(l) if sp.policy.serial < l.serial => {}
407 +
                    Some(l) if sp.policy.serial == l.serial => {
408 +
                        if sp.policy.hash() != l.hash() {
409 +
                            return fail(format!("{remote_name} presents a different policy with the same serial {}", l.serial));
410 +
                        }
411 +
                    }
412 +
                    local => {
413 +
                        self.check_policy(sp, local.as_ref()).map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?;
414 +
                        self.set_policy(sp, &format!("oxy fetch {remote_name} (policy serial {})", sp.policy.serial))?;
415 +
                    }
416 +
                }
417 +
                self.record_policy_serial(remote_name, sp.policy.serial)?;
418 +
            }
419 +
            None => {
420 +
                if seen.is_some() {
421 +
                    return fail(format!("{remote_name} presented a policy before but has none now: refusing the downgrade"));
422 +
                }
423 +
                unverified = true;
424 +
            }
425 +
        }
426 +
        let policy = if unverified { None } else { self.current_policy()?.map(|(_, sp)| sp.policy) };
328 427
        let (op_ref, op) = self.head_op()?;
329 428
        let mut view = op.view.clone();
330 429
        let have = self.known_heads(&view)?;
331 430
        let mut report = Vec::new();
332 -
        for r in remote.refs()? {
431 +
        for r in resp.refs {
333 432
            let needed = remote.negotiate(&have, &r.heads)?;
334 433
            let bundle = remote.fetch(&needed)?;
434 +
            // Changes are not individually re-verified here: the bookmark's
435 +
            // attestation (checked below) binds its whole state, and the
436 +
            // receiver of the push verified the added changes at that time.
335 437
            let imported = self.import_bundle(&bundle, false)?;
336 438
            for h in &r.heads {
337 439
                if !self.store.has(h) {
…
4 unchanged lines
342 444
            if state != r.state {
343 445
                return fail(format!("remote state {} does not match its heads", r.state.short(12)));
344 446
            }
447 +
            if let Some(p) = &policy {
448 +
                let Some(sa) = &r.attestation else {
449 +
                    return fail(format!("{remote_name}/{} has no attestation, which its policy requires", r.name));
450 +
                };
451 +
                let signer = sa.sigs.first().map(|s| s.key).ok_or_else(|| RepoError::Msg("attestation carries no signature".into()))?;
452 +
                if !p.authorizes(&signer, &r.name) {
453 +
                    return fail(format!("{remote_name}/{}: attested by {}, which the policy does not authorize", r.name, signer.short(12)));
454 +
                }
455 +
            }
345 456
            if let Some(sa) = &r.attestation {
346 457
                let AttestBody::Ref { old_state, new_state, .. } = &sa.attest.body else {
347 458
                    return fail("remote sent a non-ref attestation for a bookmark");
…
17 unchanged lines
365 476
        if view != op.view {
366 477
            self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?;
367 478
        }
368 -
        Ok(report)
479 +
        Ok(FetchReport { refs: report, unverified })
369 480
    }
370 481
371 482
    /// Fetch, then take the union of the local bookmark's state with the
…
43 unchanged lines
415 526
        let (op_ref, op) = self.head_op()?;
416 527
        let bookmark = self.config.bookmark.clone();
417 528
        let local_heads = self.heads_of(&op.view, &bookmark)?;
418 -
        let refs = remote.refs()?;
529 +
        let resp = remote.refs()?;
530 +
        let local_policy = self.current_policy()?.map(|(_, sp)| sp);
531 +
        let policy_to_send = match (&local_policy, &resp.policy) {
532 +
            (Some(l), Some(r)) if l.policy.serial > r.policy.serial => Some(l.clone()),
533 +
            (Some(l), None) => Some(l.clone()),
534 +
            (Some(l), Some(r)) if l.policy.serial < r.policy.serial => {
535 +
                return fail(format!("{remote_name} has a newer policy (serial {}); fetch first", r.policy.serial));
536 +
            }
537 +
            _ => None,
538 +
        };
539 +
        let remote_has_policy = resp.policy.is_some() || policy_to_send.is_some();
540 +
        if remote_has_policy && self.secret_key()?.is_none() {
541 +
            return fail(format!("{remote_name} enforces a trust policy; pushing needs an identity (`oxy identity create`)"));
542 +
        }
543 +
        let refs = resp.refs;
419 544
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
420 545
        let (remote_state, remote_heads, last_serial) = match refs.iter().find(|r| r.name == bookmark) {
421 546
            Some(r) => (r.state, r.heads.clone(), r.last_serial),
422 547
            None => (empty, vec![], None),
423 548
        };
424 549
        let new_state = self.store.put_state(&StateObject::new(local_heads.clone()))?;
425 -
        if new_state == remote_state {
550 +
        // A push that carries a policy re-attests the bookmark even when its
551 +
        // state is unchanged, so the current attestation is always by a key
552 +
        // the current policy authorizes (a revoked key's attestation would
553 +
        // otherwise be refused by every later fetch).
554 +
        if new_state == remote_state && policy_to_send.is_none() {
426 555
            return Ok(vec![RefResult { name: bookmark, ok: true, reason: "already up to date".into() }]);
427 556
        }
428 557
        for h in &remote_heads {
…
38 unchanged lines
467 596
            AttestBody::Ref { serial, .. } => Some(serial),
468 597
            _ => None,
469 598
        });
470 -
        let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }])?;
471 -
        if results.iter().any(|r| r.ok) {
599 +
        let sent_serial = policy_to_send.as_ref().map(|p| p.policy.serial);
600 +
        let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }], policy_to_send)?;
601 +
        if let (Some(serial), true) = (sent_serial, results.iter().any(|r| r.ok && r.name == "policy")) {
602 +
            self.record_policy_serial(remote_name, serial)?;
603 +
        }
604 +
        if results.iter().any(|r| r.ok && r.name == bookmark) {
472 605
            let mut view = op.view.clone();
473 606
            view.remotes.entry(remote_name.to_string()).or_default().insert(bookmark.clone(), new_state);
474 607
            self.record_op(&[op_ref], &format!("oxy push {remote_name}"), view)?;
…
32 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +98 −6
1 1
//! Tests for the repository layer.
2 2
3 3
use super::*;
4 +
use crate::protocol::SignedPolicy;
4 5
5 6
/// Point `$OXY_HOME` at a throwaway directory once per test process so no
6 7
/// test reads or writes the developer's real user configuration or keys.
…
469 unchanged lines
476 477
477 478
    // Missing head.
478 479
    let ghost = origin.store().put_state(&StateObject::new(vec![ObjectRef([9; 32])])).unwrap();
479 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }]).unwrap();
480 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }], None).unwrap();
480 481
    assert!(!r[0].ok && r[0].reason.contains("missing"), "{}", r[0].reason);
481 482
482 483
    // Stale old_state.
483 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }]).unwrap();
484 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }], None).unwrap();
484 485
    assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason);
485 486
486 487
    // Non-fast-forward without force is refused; with force it passes.
487 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }]).unwrap();
488 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }], None).unwrap();
488 489
    assert!(!r[0].ok && r[0].reason.contains("fast-forward"), "{}", r[0].reason);
489 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }]).unwrap();
490 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }], None).unwrap();
490 491
    assert!(r[0].ok);
491 492
    assert_eq!(origin.bookmark_heads().unwrap(), vec![]);
492 493
493 494
    // Attestation with a stale serial is a rollback and is refused.
494 495
    let sa = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: empty, new_state: s1, serial: 1 } }).unwrap();
495 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }]).unwrap();
496 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }], None).unwrap();
496 497
    assert!(!r[0].ok && r[0].reason.contains("serial"), "{}", r[0].reason);
497 498
    // An attestation for a different update is refused too.
498 499
    let sa2 = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: s1, new_state: empty, serial: 5 } }).unwrap();
499 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }]).unwrap();
500 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }], None).unwrap();
500 501
    assert!(!r[0].ok && r[0].reason.contains("describe"), "{}", r[0].reason);
501 502
    let _ = key;
502 503
}
…
154 unchanged lines
657 658
    assert!(matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n"));
658 659
    assert!(!snap.is_conflicted());
659 660
}
661 +
662 +
#[test]
663 +
fn trust_policy_governs_pushes_and_fetches() {
664 +
    hermetic();
665 +
    let dir = tempfile::tempdir().unwrap();
666 +
    let root = dir.path();
667 +
    let origin = Repo::init(&root.join("origin")).unwrap();
668 +
    let url = root.join("origin").to_string_lossy().to_string();
669 +
670 +
    // A is the root. Before the policy, an unsigned push from B is fine.
671 +
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home-a"));
672 +
    let (key_a, _) = a.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
673 +
    let mut b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b"));
674 +
    write(&root.join("b"), "b.txt", b"b\n");
675 +
    b.commit("unsigned before policy", &[]).unwrap();
676 +
    assert!(b.push("origin", false).unwrap()[0].ok);
677 +
678 +
    // A creates the policy and pushes it; origin now enforces it.
679 +
    write(&root.join("a"), "a.txt", b"a\n");
680 +
    a.commit("a", &[]).unwrap();
681 +
    let sp = a.policy_init().unwrap();
682 +
    assert_eq!(sp.policy.serial, 1);
683 +
    a.pull("origin").unwrap();
684 +
    let results = a.push("origin", false).unwrap();
685 +
    assert!(results.iter().all(|r| r.ok), "{results:?}");
686 +
    assert_eq!(origin.current_policy().unwrap().unwrap().1.policy.serial, 1);
687 +
688 +
    // B pulls the policy. Without an identity B cannot push at all.
689 +
    b.pull("origin").unwrap();
690 +
    assert!(b.current_policy().unwrap().is_some(), "the policy arrived with the fetch");
691 +
    assert!(b.push("origin", false).is_err());
692 +
    // With an identity the policy does not list, the push is rejected by origin.
693 +
    let (key_b, id_b) = b.create_identity("Bob", "[email protected]", IdentityScope::User).unwrap();
694 +
    write(&root.join("b"), "b.txt", b"b2\n");
695 +
    let h_b2 = b.commit("b2 as Bob", &[]).unwrap();
696 +
    assert_eq!(b.store().get_change(&h_b2).unwrap().author, key_b);
697 +
    let r = b.push("origin", false).unwrap();
698 +
    assert!(!r[0].ok && r[0].reason.contains("may not update"), "{:?}", r);
699 +
700 +
    // A grants B (identity handed over as a file) and publishes the policy.
701 +
    let sp = a.policy_grant(id_b.clone(), None).unwrap();
702 +
    assert_eq!(sp.policy.serial, 2);
703 +
    let r = a.push("origin", false).unwrap();
704 +
    assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}");
705 +
    // B must fetch the new policy before pushing.
706 +
    assert!(b.push("origin", false).unwrap_or_else(|e| vec![RefResult { name: "main".into(), ok: false, reason: e.to_string() }]).iter().all(|x| !x.ok));
707 +
    b.pull("origin").unwrap();
708 +
    assert_eq!(b.current_policy().unwrap().unwrap().1.policy.serial, 2);
709 +
    let r = b.push("origin", false).unwrap();
710 +
    assert!(r[0].ok, "{:?}", r);
711 +
    // Origin verified B's change under the policy.
712 +
    let heads = origin.bookmark_heads().unwrap();
713 +
    for h in &heads {
714 +
        let c = origin.store().get_change(h).unwrap();
715 +
        if c.author == key_b {
716 +
            origin.change_allowed(h, &origin.current_policy().unwrap().unwrap().1.policy, "main").unwrap();
717 +
        }
718 +
    }
719 +
720 +
    // A policy signed by a non-root (B) is refused, as is a replayed serial.
721 +
    let mut forged = b.current_policy().unwrap().unwrap().1.policy.clone();
722 +
    forged.serial = 3;
723 +
    forged.roots = vec![key_b];
724 +
    let fh = forged.hash();
725 +
    let (_, sk_b) = b.secret_key().unwrap().unwrap();
726 +
    let forged = SignedPolicy { policy: forged, sigs: vec![sk_b.sign(Domain::Policy, &key_b, &fh)] };
727 +
    let r = origin.receive_push(Bundle::default(), vec![], Some(forged)).unwrap();
728 +
    assert!(!r[0].ok && r[0].reason.contains("root"), "{:?}", r);
729 +
    let stale = a.current_policy().unwrap().unwrap().1;
730 +
    let mut old = stale.clone();
731 +
    old.policy.serial = 1;
732 +
    let r = origin.receive_push(Bundle::default(), vec![], Some(old)).unwrap();
733 +
    assert!(!r[0].ok && r[0].reason.contains("serial"), "{:?}", r);
734 +
735 +
    // A revokes B; after B pulls the policy, B's pushes are refused again.
736 +
    a.pull("origin").unwrap();
737 +
    a.policy_revoke(&key_b).unwrap();
738 +
    let r = a.push("origin", false).unwrap();
739 +
    assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}");
740 +
    b.pull("origin").unwrap();
741 +
    write(&root.join("b"), "b.txt", b"b3\n");
742 +
    b.commit("b3", &[]).unwrap();
743 +
    let r = b.push("origin", false).unwrap();
744 +
    assert!(!r[0].ok, "{:?}", r);
745 +
746 +
    // A clone by a third party receives and verifies everything.
747 +
    let c = Repo::clone(&url, &root.join("c")).unwrap().with_home(root.join("home-c"));
748 +
    assert_eq!(c.current_policy().unwrap().unwrap().1.policy.serial, 3);
749 +
    assert!(c.current_policy().unwrap().unwrap().1.policy.roots.contains(&key_a));
750 +
    assert!(!c.snapshot("status").unwrap().changed);
751 +
}
modified crates/oxy-server/src/lib.rs +2 −2
…
140 unchanged lines
141 141
    app.authorize_push(&headers)?;
142 142
    blocking(move || {
143 143
        let r = app.open(&repo)?;
144 -
        let (bundle, updates) = wire::decode_push(&body)?;
144 +
        let (bundle, updates, policy) = wire::decode_push(&body)?;
145 145
        let lock = app.lock_for(&repo);
146 146
        let _guard = lock.lock().unwrap();
147 -
        Ok(ok(wire::encode_push_response(&r.receive_push(bundle, updates)?)))
147 +
        Ok(ok(wire::encode_push_response(&r.receive_push(bundle, updates, policy)?)))
148 148
    })
149 149
    .await
150 150
}
…
36 unchanged lines
modified docs/spec.md +12 −6
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.5b |
5 +
| **Status** | Draft 0.6 |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
545 unchanged lines
554 554
  ml   [3]: bytes(3309)
555 555
}
556 556
message = "oxy1 sig " || domain || 0x00 || KeyId || ObjectRef-being-signed
557 -
domain  = "change" | "attest"
557 +
domain  = "change" | "attest" | "policy"
558 558
```
559 559
560 560
Both signatures are computed over the same `message`, which includes the `KeyId` of the composite pair. Verification MUST require both signatures to verify with the keys of the identity named by `key`. A signature with either half missing, empty, or failing is invalid. This is the same construction as the IETF LAMPS composite signature drafts and prevents stripping the post-quantum half.
…
47 unchanged lines
608 608
- The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys and have a higher `serial`.
609 609
- A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of `new_state` not previously in the closure of `old_state`. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`.
610 610
- Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push.
611 -
- A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy init` creates one by default.
611 +
- A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy policy init` creates one with the author's key as the sole root; `oxy policy grant` and `oxy policy revoke` produce successors.
612 +
- **Propagation.** The `refs` response carries the current signed policy and a push MAY carry a newer one, applied before any bookmark update. A receiver accepts a policy only if it is for the same repository, its serial exceeds the current one, and it is signed by the current policy's root threshold; the first policy a repository ever sees vouches for itself.
613 +
- **Downgrade protection.** A client records the highest policy serial each remote has presented. A remote that later presents none, or a lower serial, is refused. A remote that has never presented a policy is unverified: the client fetches with a warning and cannot verify what it received. Policy signatures use the domain `policy` (§14.3).
614 +
- **Existing history.** A policy governs changes added after it. The receiver of a push checks the changes the new state adds beyond the state it already had, so history that predates the policy is not re-verified. A fetching client verifies the bookmark's attestation, which binds the whole state through its head hashes, and does not re-verify individual changes; it therefore trusts what an authorized attester vouched for, which is the point of the policy.
612 615
613 616
## 15. Transport
614 617
…
54 unchanged lines
669 672
| Signed attestation | `[attest bytes, [Signature]]` |
670 673
| `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]]}`; changes in dependency order; objects are chunks, blobs, identities, and states only |
671 674
| `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` |
672 -
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id}`; the attestation is present only when it describes the current state |
675 +
| Signed policy | `[policy bytes, [Signature]]` |
676 +
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null}`; the attestation is present only when it describes the current state |
673 677
| `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order |
674 678
| `fetch` request | `{0: [hashes]}`; response a `Bundle` |
675 -
| `push` request | `{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}]}`; response `{0: [{0: name, 1: ok, 2: reason}]}` |
679 +
| `push` request | `{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}], 2: signed policy or null}`; response `{0: [{0: name, 1: ok, 2: reason}]}`, where a result named `policy` reports the policy update |
676 680
677 681
The HTTP content type is `application/oxy-cbor`. The `objects` and `checkpoint` endpoints of §16.1 are not yet specified in this form.
678 682
…
141 unchanged lines
820 824
821 825
---
822 826
823 -
*Changelog.* Draft 0.5b (2026-09-26): §19 defines the user configuration file's keys, the precedence of repository over user over environment, and the key directory layout.
827 +
*Changelog.* Draft 0.6 (2026-09-26): trust policy propagation over the wire, the `policy` signing domain, per-remote downgrade protection, the bootstrap rule for unverified remotes, and the rule that policies govern only changes added after them.
828 +
829 +
Draft 0.5b (2026-09-26): §19 defines the user configuration file's keys, the precedence of repository over user over environment, and the key directory layout.
824 830
825 831
Draft 0.5a (2026-09-26): §15.1 allows a reference server behind TLS termination and requires authenticated pushes when credentials exist; §16.3 defines the CBOR message encodings (push validation moves to §16.4).
826 832
…
11 unchanged lines
modified src/cli/identity.rs +75 −0
…
3 unchanged lines
4 4
use super::open;
5 5
use anyhow::Result;
6 6
use oxy_core::hash::ChangeHash;
7 +
use oxy_core::identity::Identity;
7 8
use oxy_core::repo::{self, IdentityScope};
9 +
use std::path::{Path, PathBuf};
8 10
9 11
pub fn create(name: Option<String>, email: Option<String>, local: bool) -> Result<i32> {
10 12
    let mut repo = open()?;
…
60 unchanged lines
71 73
    }
72 74
    Ok(0)
73 75
}
76 +
77 +
pub fn export(path: Option<PathBuf>) -> Result<i32> {
78 +
    let repo = open()?;
79 +
    let Some(key) = repo.config.author_key else {
80 +
        anyhow::bail!("no identity configured; run `oxy identity create`");
81 +
    };
82 +
    let id = repo.identity(&key)?;
83 +
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16))));
84 +
    std::fs::write(&path, id.encode())?;
85 +
    println!("Wrote identity {} <{}> ({}) to {}", id.name, id.email, key.short(12), path.display());
86 +
    Ok(0)
87 +
}
88 +
89 +
pub fn policy_init() -> Result<i32> {
90 +
    let repo = open()?;
91 +
    let sp = repo.policy_init()?;
92 +
    println!("Created policy serial {} with root key {}; push to publish it.", sp.policy.serial, sp.policy.roots[0].short(12));
93 +
    Ok(0)
94 +
}
95 +
96 +
pub fn policy_show() -> Result<i32> {
97 +
    let repo = open()?;
98 +
    let Some((r, sp)) = repo.current_policy()? else {
99 +
        println!("No policy: this repository is unverified. Run `oxy policy init` to create one.");
100 +
        return Ok(0);
101 +
    };
102 +
    let p = &sp.policy;
103 +
    let name = |k: &oxy_core::hash::KeyId| p.identity(k).map(|i| format!("{} <{}>", i.name, i.email)).unwrap_or_else(|| "(unknown identity)".into());
104 +
    println!("policy #{} serial {} expires {}{}", r.short(12), p.serial, when(p.expires), if p.is_expired(oxy_core::objects::Hlc::now().wall_ms) { "  EXPIRED" } else { "" });
105 +
    println!("roots (threshold {}):", p.root_threshold);
106 +
    for k in &p.roots {
107 +
        println!("  {}  {}", k.short(12), name(k));
108 +
    }
109 +
    println!("rules:");
110 +
    for rule in &p.rules {
111 +
        println!("  {:12} threshold {} require {} conflicts {}", rule.pattern, rule.threshold, rule.require.name(), if rule.allow_conflicts { "allowed" } else { "refused" });
112 +
        for k in &rule.keys {
113 +
            println!("      {}  {}", k.short(12), name(k));
114 +
        }
115 +
    }
116 +
    if !p.revoked.is_empty() {
117 +
        println!("revoked:");
118 +
        for k in &p.revoked {
119 +
            println!("  {}  {}", k.short(12), name(k));
120 +
        }
121 +
    }
122 +
    Ok(0)
123 +
}
124 +
125 +
pub fn policy_grant(identity_file: &Path, pattern: Option<&str>) -> Result<i32> {
126 +
    let repo = open()?;
127 +
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
128 +
    let key = id.hash();
129 +
    let sp = repo.policy_grant(id.clone(), pattern)?;
130 +
    println!("Granted {} <{}> ({}) on {}; policy serial {}. Push to publish.", id.name, id.email, key.short(12), pattern.unwrap_or("*"), sp.policy.serial);
131 +
    Ok(0)
132 +
}
133 +
134 +
pub fn policy_revoke(key: &str) -> Result<i32> {
135 +
    let repo = open()?;
136 +
    let Some((_, sp)) = repo.current_policy()? else {
137 +
        anyhow::bail!("this repository has no policy");
138 +
    };
139 +
    let matches: Vec<_> = sp.policy.identities.iter().map(|i| i.hash()).filter(|k| k.starts_with_hex(key)).collect();
140 +
    let full = match matches.len() {
141 +
        1 => matches[0],
142 +
        0 => anyhow::bail!("no key in the policy starts with {key:?}"),
143 +
        _ => anyhow::bail!("{key:?} is ambiguous"),
144 +
    };
145 +
    let sp = repo.policy_revoke(&full)?;
146 +
    println!("Revoked {}; policy serial {}. Push to publish.", full.short(12), sp.policy.serial);
147 +
    Ok(0)
148 +
}
modified src/cli/mod.rs +31 −0
…
95 unchanged lines
96 96
    },
97 97
    /// Verify the signatures on a change (default: every change in the log)
98 98
    Verify { change: Option<String> },
99 +
    /// Manage the trust policy (who may update which bookmarks)
100 +
    Policy {
101 +
        #[command(subcommand)]
102 +
        cmd: PolicyCmd,
103 +
    },
99 104
    /// Set a configuration key (author.*, core.sign, workspace.bookmark, remote.<name>.url|token|ca)
100 105
    Config {
101 106
        key: String,
…
30 unchanged lines
132 137
    },
133 138
    /// Show the configured identity
134 139
    Show,
140 +
    /// Write your identity object to a file, to hand to a policy holder
141 +
    Export { path: Option<PathBuf> },
135 142
}
136 143
144 +
#[derive(Subcommand)]
145 +
enum PolicyCmd {
146 +
    /// Create the initial policy with your identity as the only root
147 +
    Init,
148 +
    /// Show the current policy
149 +
    Show,
150 +
    /// Allow an identity (from `oxy identity export`) to update bookmarks
151 +
    Grant {
152 +
        identity_file: PathBuf,
153 +
        /// Reference pattern the grant applies to (default: every bookmark)
154 +
        #[arg(long)]
155 +
        pattern: Option<String>,
156 +
    },
157 +
    /// Revoke a key (KeyId prefix of an identity in the policy)
158 +
    Revoke { key: String },
159 +
}
160 +
137 161
/// Open the repository containing the current directory.
138 162
fn open() -> Result<Repo> {
139 163
    Repo::open(&std::env::current_dir()?).context("opening repository")
…
26 unchanged lines
166 190
        Cmd::Identity { cmd } => match cmd {
167 191
            IdentityCmd::Create { name, email, local } => identity::create(name, email, local),
168 192
            IdentityCmd::Show => identity::show(),
193 +
            IdentityCmd::Export { path } => identity::export(path),
194 +
        },
195 +
        Cmd::Policy { cmd } => match cmd {
196 +
            PolicyCmd::Init => identity::policy_init(),
197 +
            PolicyCmd::Show => identity::policy_show(),
198 +
            PolicyCmd::Grant { identity_file, pattern } => identity::policy_grant(&identity_file, pattern.as_deref()),
199 +
            PolicyCmd::Revoke { key } => identity::policy_revoke(&key),
169 200
        },
170 201
        Cmd::Verify { change } => identity::verify(change.as_deref()),
171 202
        Cmd::Config { key, value, user } => identity::config(&key, &value, user),
…
2 unchanged lines
modified src/cli/sync.rs +6 −2
…
54 unchanged lines
55 55
pub fn fetch(remote: Option<&str>) -> Result<i32> {
56 56
    let repo = open()?;
57 57
    let remote = remote_name(remote);
58 -
    for (name, heads, n) in repo.fetch(&remote)? {
59 -
        println!("{remote}/{name}: {}, {}", plural(heads.len(), "head"), plural(n, "new change"));
58 +
    let report = repo.fetch(&remote)?;
59 +
    for (name, heads, n) in &report.refs {
60 +
        println!("{remote}/{name}: {}, {}", plural(heads.len(), "head"), plural(*n, "new change"));
61 +
    }
62 +
    if report.unverified {
63 +
        eprintln!("warning: {remote} has no trust policy; nothing fetched from it was verified");
60 64
    }
61 65
    Ok(0)
62 66
}
…
53 unchanged lines