OXYGENHUB

Identities and composite Ed25519 + ML-DSA-65 signatures; oxy identity, verify, config 1983686fb7a7

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 18:12 UTC
Signature
✔ signed by Andy McCall
Hash
1983686fb7a7f984625c2b2185581f0b95abed59946c2f5fc097bf07f18fbeaf
Depends on
f9c98deef326 ff0af1ee299d
Depended on by
ef7d4ea6f33b e5f8eb2a2006 a2387d547373 02e691881a4e a39754d02c58 54e2e6309aa3 648189388f1a 7ba155f20aa0 d443639bcf35 6f8aeb996558 4dcb8c0916b7 119bbf50cb86 7aa2dc9369e3 75167672ebcd 28fc23d7706a ba160f60219e d02e545dc265 83e0677b07fe fce44ae9ad29 750251b21a96 5b9c6ad45f6e 86e682c242c6 20247a03a596 5b1114a04905
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
31 atoms, 1 tree operation, 5 files

Browse the tree at this change

added crates/oxy-core/src/identity.rs +343 −0
1 +
//! Identities and composite Ed25519 + ML-DSA-65 signatures (spec §14.2, §14.3).
2 +
//!
3 +
//! Both halves sign the same message, which binds the composite `KeyId`, and
4 +
//! verification requires both. A signature with either half missing, wrong,
5 +
//! or made for a different KeyId is invalid.
6 +
7 +
use crate::cbor::{self, Value};
8 +
use crate::hash::{hash_object, KeyId, ObjectRef, ObjectType};
9 +
use crate::objects::{array, bytes, check_version, field, oref, text, uint, ObjectError, FORMAT_VERSION};
10 +
use ed25519_dalek::ed25519::signature::Signer as _;
11 +
use ed25519_dalek::{Signature as EdSignature, SigningKey as EdSigningKey, VerifyingKey as EdVerifyingKey};
12 +
use ml_dsa::{EncodedSignature, EncodedVerifyingKey, MlDsa65, Seed, Signature as MlSignature, SigningKey as MlSigningKey, VerifyingKey as MlVerifyingKey};
13 +
use rand::RngCore;
14 +
15 +
pub const ED25519_PK_LEN: usize = 32;
16 +
pub const MLDSA65_PK_LEN: usize = 1952;
17 +
pub const ED25519_SIG_LEN: usize = 64;
18 +
pub const MLDSA65_SIG_LEN: usize = 3309;
19 +
/// `alg` value for Ed25519 + ML-DSA-65.
20 +
pub const ALG_COMPOSITE: u64 = 1;
21 +
22 +
#[derive(Clone, Debug, PartialEq, Eq)]
23 +
pub struct Identity {
24 +
    pub ed25519_pk: [u8; ED25519_PK_LEN],
25 +
    pub mldsa65_pk: Vec<u8>,
26 +
    pub name: String,
27 +
    pub email: String,
28 +
    pub created: u64,
29 +
    pub expires: Option<u64>,
30 +
    pub previous: Option<KeyId>,
31 +
    pub delegates: Vec<KeyId>,
32 +
}
33 +
34 +
impl Identity {
35 +
    pub fn to_value(&self) -> Value {
36 +
        Value::map([
37 +
            (0, Value::Uint(FORMAT_VERSION)),
38 +
            (1, Value::Bytes(self.ed25519_pk.to_vec())),
39 +
            (2, Value::Bytes(self.mldsa65_pk.clone())),
40 +
            (3, Value::Text(self.name.clone())),
41 +
            (4, Value::Text(self.email.clone())),
42 +
            (5, Value::Uint(self.created)),
43 +
            (6, self.expires.map(Value::Uint).unwrap_or(Value::Null)),
44 +
            (7, self.previous.map(|p| Value::Bytes(p.0.to_vec())).unwrap_or(Value::Null)),
45 +
            (8, Value::Array(self.delegates.iter().map(|d| Value::Bytes(d.0.to_vec())).collect())),
46 +
        ])
47 +
    }
48 +
    pub fn encode(&self) -> Vec<u8> {
49 +
        cbor::encode(&self.to_value())
50 +
    }
51 +
    pub fn hash(&self) -> KeyId {
52 +
        hash_object(ObjectType::Identity, &self.encode())
53 +
    }
54 +
    pub fn decode(b: &[u8]) -> Result<Identity, ObjectError> {
55 +
        let v = cbor::decode(b)?;
56 +
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("identity: expected map".into()))?;
57 +
        check_version(m)?;
58 +
        let ed = bytes(field(m, 1, "ed25519_pk")?, "ed25519_pk")?;
59 +
        let ml = bytes(field(m, 2, "mldsa65_pk")?, "mldsa65_pk")?;
60 +
        if ed.len() != ED25519_PK_LEN || ml.len() != MLDSA65_PK_LEN {
61 +
            return Err(ObjectError::Schema("identity: wrong public key length".into()));
62 +
        }
63 +
        let opt_u = |v: &Value| -> Result<Option<u64>, ObjectError> { if v.is_null() { Ok(None) } else { uint(v, "expires").map(Some) } };
64 +
        let opt_r = |v: &Value| -> Result<Option<KeyId>, ObjectError> { if v.is_null() { Ok(None) } else { oref(v, "previous").map(Some) } };
65 +
        let mut delegates = array(field(m, 8, "delegates")?, "delegates")?.iter().map(|d| oref(d, "delegate")).collect::<Result<Vec<_>, _>>()?;
66 +
        delegates.sort();
67 +
        delegates.dedup();
68 +
        Ok(Identity {
69 +
            ed25519_pk: ed.try_into().unwrap(),
70 +
            mldsa65_pk: ml.to_vec(),
71 +
            name: text(field(m, 3, "name")?, "name")?.to_owned(),
72 +
            email: text(field(m, 4, "email")?, "email")?.to_owned(),
73 +
            created: uint(field(m, 5, "created")?, "created")?,
74 +
            expires: opt_u(field(m, 6, "expires")?)?,
75 +
            previous: opt_r(field(m, 7, "previous")?)?,
76 +
            delegates,
77 +
        })
78 +
    }
79 +
80 +
    fn ed_key(&self) -> Result<EdVerifyingKey, VerifyError> {
81 +
        EdVerifyingKey::from_bytes(&self.ed25519_pk).map_err(|_| VerifyError::BadKey)
82 +
    }
83 +
    fn ml_key(&self) -> Result<MlVerifyingKey<MlDsa65>, VerifyError> {
84 +
        let enc = EncodedVerifyingKey::<MlDsa65>::try_from(self.mldsa65_pk.as_slice()).map_err(|_| VerifyError::BadKey)?;
85 +
        Ok(MlVerifyingKey::decode(&enc))
86 +
    }
87 +
}
88 +
89 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90 +
pub enum Domain {
91 +
    Change,
92 +
    Attest,
93 +
}
94 +
95 +
impl Domain {
96 +
    pub fn name(self) -> &'static str {
97 +
        match self {
98 +
            Domain::Change => "change",
99 +
            Domain::Attest => "attest",
100 +
        }
101 +
    }
102 +
}
103 +
104 +
/// `"oxy1 sig " || domain || 0x00 || KeyId || target` (spec §14.3).
105 +
pub fn signing_message(domain: Domain, key: &KeyId, target: &ObjectRef) -> Vec<u8> {
106 +
    let mut m = Vec::with_capacity(80);
107 +
    m.extend_from_slice(b"oxy1 sig ");
108 +
    m.extend_from_slice(domain.name().as_bytes());
109 +
    m.push(0);
110 +
    m.extend_from_slice(&key.0);
111 +
    m.extend_from_slice(&target.0);
112 +
    m
113 +
}
114 +
115 +
#[derive(Clone, PartialEq, Eq)]
116 +
pub struct Signature {
117 +
    pub key: KeyId,
118 +
    pub ed: [u8; ED25519_SIG_LEN],
119 +
    pub ml: Vec<u8>,
120 +
}
121 +
122 +
impl std::fmt::Debug for Signature {
123 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 +
        write!(f, "Signature(key {:?})", self.key)
125 +
    }
126 +
}
127 +
128 +
impl Signature {
129 +
    pub fn to_value(&self) -> Value {
130 +
        Value::map([
131 +
            (0, Value::Bytes(self.key.0.to_vec())),
132 +
            (1, Value::Uint(ALG_COMPOSITE)),
133 +
            (2, Value::Bytes(self.ed.to_vec())),
134 +
            (3, Value::Bytes(self.ml.clone())),
135 +
        ])
136 +
    }
137 +
    pub fn from_value(v: &Value) -> Result<Signature, ObjectError> {
138 +
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("signature: expected map".into()))?;
139 +
        let alg = uint(field(m, 1, "alg")?, "alg")?;
140 +
        if alg != ALG_COMPOSITE {
141 +
            return Err(ObjectError::Schema(format!("unknown signature algorithm {alg}")));
142 +
        }
143 +
        let ed = bytes(field(m, 2, "ed")?, "ed")?;
144 +
        let ml = bytes(field(m, 3, "ml")?, "ml")?;
145 +
        if ed.len() != ED25519_SIG_LEN || ml.len() != MLDSA65_SIG_LEN {
146 +
            return Err(ObjectError::Schema("signature: wrong length".into()));
147 +
        }
148 +
        Ok(Signature { key: oref(field(m, 0, "key")?, "key")?, ed: ed.try_into().unwrap(), ml: ml.to_vec() })
149 +
    }
150 +
    pub fn encode_list(sigs: &[Signature]) -> Vec<u8> {
151 +
        cbor::encode(&Value::Array(sigs.iter().map(|s| s.to_value()).collect()))
152 +
    }
153 +
    pub fn decode_list(b: &[u8]) -> Result<Vec<Signature>, ObjectError> {
154 +
        let v = cbor::decode(b)?;
155 +
        array(&v, "signatures")?.iter().map(Signature::from_value).collect()
156 +
    }
157 +
}
158 +
159 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
160 +
pub enum VerifyError {
161 +
    #[error("signature names a different key than the identity")]
162 +
    KeyMismatch,
163 +
    #[error("identity holds a malformed public key")]
164 +
    BadKey,
165 +
    #[error("Ed25519 signature invalid")]
166 +
    Ed25519,
167 +
    #[error("ML-DSA-65 signature invalid")]
168 +
    MlDsa,
169 +
}
170 +
171 +
/// Verify a composite signature by `identity` over `target` (spec §14.3).
172 +
pub fn verify(identity: &Identity, sig: &Signature, domain: Domain, target: &ObjectRef) -> Result<(), VerifyError> {
173 +
    let key = identity.hash();
174 +
    if sig.key != key {
175 +
        return Err(VerifyError::KeyMismatch);
176 +
    }
177 +
    let msg = signing_message(domain, &key, target);
178 +
    let ed_sig = EdSignature::from_bytes(&sig.ed);
179 +
    identity.ed_key()?.verify_strict(&msg, &ed_sig).map_err(|_| VerifyError::Ed25519)?;
180 +
    let enc = EncodedSignature::<MlDsa65>::try_from(sig.ml.as_slice()).map_err(|_| VerifyError::MlDsa)?;
181 +
    let ml_sig = MlSignature::<MlDsa65>::decode(&enc).ok_or(VerifyError::MlDsa)?;
182 +
    if !identity.ml_key()?.verify_with_context(&msg, b"", &ml_sig) {
183 +
        return Err(VerifyError::MlDsa);
184 +
    }
185 +
    Ok(())
186 +
}
187 +
188 +
/// The seeds of a composite key pair. Stored only in the user's key directory.
189 +
#[derive(Clone)]
190 +
pub struct SecretKey {
191 +
    pub ed_seed: [u8; 32],
192 +
    pub ml_seed: [u8; 32],
193 +
}
194 +
195 +
impl std::fmt::Debug for SecretKey {
196 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 +
        f.write_str("SecretKey(..)")
198 +
    }
199 +
}
200 +
201 +
impl SecretKey {
202 +
    pub fn generate() -> SecretKey {
203 +
        let mut ed_seed = [0u8; 32];
204 +
        let mut ml_seed = [0u8; 32];
205 +
        rand::rngs::OsRng.fill_bytes(&mut ed_seed);
206 +
        rand::rngs::OsRng.fill_bytes(&mut ml_seed);
207 +
        SecretKey { ed_seed, ml_seed }
208 +
    }
209 +
210 +
    fn ed(&self) -> EdSigningKey {
211 +
        EdSigningKey::from_bytes(&self.ed_seed)
212 +
    }
213 +
    fn ml(&self) -> MlSigningKey<MlDsa65> {
214 +
        MlSigningKey::from_seed(&Seed::from(self.ml_seed))
215 +
    }
216 +
217 +
    /// The identity object for these keys.
218 +
    pub fn identity(&self, name: &str, email: &str, created: u64) -> Identity {
219 +
        Identity {
220 +
            ed25519_pk: self.ed().verifying_key().to_bytes(),
221 +
            mldsa65_pk: self.ml().expanded_key().verifying_key().encode().as_slice().to_vec(),
222 +
            name: name.into(),
223 +
            email: email.into(),
224 +
            created,
225 +
            expires: None,
226 +
            previous: None,
227 +
            delegates: vec![],
228 +
        }
229 +
    }
230 +
231 +
    pub fn sign(&self, domain: Domain, key: &KeyId, target: &ObjectRef) -> Signature {
232 +
        let msg = signing_message(domain, key, target);
233 +
        let ed = self.ed().sign(&msg).to_bytes();
234 +
        let ml = self.ml().expanded_key().sign_deterministic(&msg, b"").expect("empty context is valid").encode().as_slice().to_vec();
235 +
        Signature { key: *key, ed, ml }
236 +
    }
237 +
238 +
    pub fn encode(&self, key: &KeyId) -> Vec<u8> {
239 +
        cbor::encode(&Value::map([
240 +
            (0, Value::Uint(FORMAT_VERSION)),
241 +
            (1, Value::Bytes(key.0.to_vec())),
242 +
            (2, Value::Bytes(self.ed_seed.to_vec())),
243 +
            (3, Value::Bytes(self.ml_seed.to_vec())),
244 +
        ]))
245 +
    }
246 +
247 +
    pub fn decode(b: &[u8]) -> Result<(KeyId, SecretKey), ObjectError> {
248 +
        let v = cbor::decode(b)?;
249 +
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("secret key: expected map".into()))?;
250 +
        check_version(m)?;
251 +
        let seed = |k: u64| -> Result<[u8; 32], ObjectError> {
252 +
            bytes(field(m, k, "seed")?, "seed")?.try_into().map_err(|_| ObjectError::Schema("seed: expected 32 bytes".into()))
253 +
        };
254 +
        Ok((oref(field(m, 1, "key")?, "key")?, SecretKey { ed_seed: seed(2)?, ml_seed: seed(3)? }))
255 +
    }
256 +
}
257 +
258 +
#[cfg(test)]
259 +
mod tests {
260 +
    use super::*;
261 +
262 +
    fn fixed_key() -> SecretKey {
263 +
        SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] }
264 +
    }
265 +
266 +
    #[test]
267 +
    fn identity_round_trip_and_sizes() {
268 +
        let sk = fixed_key();
269 +
        let id = sk.identity("Ada", "[email protected]", 1_700_000_000_000);
270 +
        assert_eq!(id.mldsa65_pk.len(), MLDSA65_PK_LEN);
271 +
        let d = Identity::decode(&id.encode()).unwrap();
272 +
        assert_eq!(d, id);
273 +
        assert_eq!(d.hash(), id.hash());
274 +
        // Deterministic from seeds.
275 +
        assert_eq!(fixed_key().identity("Ada", "[email protected]", 1_700_000_000_000).hash(), id.hash());
276 +
        let mut other = id.clone();
277 +
        other.delegates.push(ObjectRef([9; 32]));
278 +
        assert_ne!(other.hash(), id.hash());
279 +
    }
280 +
281 +
    #[test]
282 +
    fn sign_and_verify() {
283 +
        let sk = fixed_key();
284 +
        let id = sk.identity("Ada", "[email protected]", 1);
285 +
        let key = id.hash();
286 +
        let target = ObjectRef([7; 32]);
287 +
        let sig = sk.sign(Domain::Change, &key, &target);
288 +
        assert_eq!(sig.ml.len(), MLDSA65_SIG_LEN);
289 +
        assert_eq!(verify(&id, &sig, Domain::Change, &target), Ok(()));
290 +
        // Different target, different domain: invalid.
291 +
        assert_eq!(verify(&id, &sig, Domain::Change, &ObjectRef([8; 32])), Err(VerifyError::Ed25519));
292 +
        assert_eq!(verify(&id, &sig, Domain::Attest, &target), Err(VerifyError::Ed25519));
293 +
        // Deterministic signatures.
294 +
        assert_eq!(sk.sign(Domain::Change, &key, &target), sig);
295 +
        // Signature list round trip.
296 +
        let l = Signature::decode_list(&Signature::encode_list(&[sig.clone()])).unwrap();
297 +
        assert_eq!(l, vec![sig]);
298 +
    }
299 +
300 +
    #[test]
301 +
    fn stripping_or_forging_either_half_fails() {
302 +
        let sk = fixed_key();
303 +
        let id = sk.identity("Ada", "a@b", 1);
304 +
        let key = id.hash();
305 +
        let target = ObjectRef([7; 32]);
306 +
        let good = sk.sign(Domain::Change, &key, &target);
307 +
308 +
        let mut no_ml = good.clone();
309 +
        no_ml.ml = vec![0; MLDSA65_SIG_LEN];
310 +
        assert_eq!(verify(&id, &no_ml, Domain::Change, &target), Err(VerifyError::MlDsa));
311 +
312 +
        let mut bad_ed = good.clone();
313 +
        bad_ed.ed[10] ^= 1;
314 +
        assert_eq!(verify(&id, &bad_ed, Domain::Change, &target), Err(VerifyError::Ed25519));
315 +
316 +
        // A signature made for another KeyId over the same target does not transfer.
317 +
        let other = SecretKey { ed_seed: [3; 32], ml_seed: [4; 32] };
318 +
        let other_id = other.identity("Bob", "b@b", 1);
319 +
        let theirs = other.sign(Domain::Change, &other_id.hash(), &target);
320 +
        assert_eq!(verify(&id, &theirs, Domain::Change, &target), Err(VerifyError::KeyMismatch));
321 +
        let mut relabelled = theirs.clone();
322 +
        relabelled.key = key;
323 +
        assert_eq!(verify(&id, &relabelled, Domain::Change, &target), Err(VerifyError::Ed25519));
324 +
325 +
        // Wrong length is rejected at decode time.
326 +
        let mut short = good.to_value();
327 +
        if let Value::Map(m) = &mut short {
328 +
            m.insert(3, Value::Bytes(vec![0; 10]));
329 +
        }
330 +
        assert!(Signature::from_value(&short).is_err());
331 +
    }
332 +
333 +
    #[test]
334 +
    fn secret_key_round_trip() {
335 +
        let sk = fixed_key();
336 +
        let key = sk.identity("x", "y", 0).hash();
337 +
        let (k, d) = SecretKey::decode(&sk.encode(&key)).unwrap();
338 +
        assert_eq!(k, key);
339 +
        assert_eq!(d.ed_seed, sk.ed_seed);
340 +
        assert_eq!(d.ml_seed, sk.ml_seed);
341 +
        assert_ne!(SecretKey::generate().ed_seed, SecretKey::generate().ed_seed);
342 +
    }
343 +
}
modified crates/oxy-core/src/lib.rs +1 −0
…
6 unchanged lines
7 7
pub mod diff;
8 8
pub mod graph;
9 9
pub mod hash;
10 +
pub mod identity;
10 11
pub mod ids;
11 12
pub mod objects;
12 13
pub mod oplog;
…
4 unchanged lines
modified crates/oxy-core/src/repo.rs +230 −18
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: signatures and identities (the author
5 -
//! is `ObjectRef::ZERO`), packs, checkpoints, multiple workspaces, remotes,
6 -
//! and resolving conflicts by editing markers.
4 +
//! Not yet implemented from the spec: seals, attestations and trust policy,
5 +
//! packs, checkpoints, multiple workspaces, remotes, and resolving conflicts
6 +
//! by editing markers. Changes are signed individually when `core.sign` is
7 +
//! `change`; the default `seal` leaves them unsigned until push exists.
7 8
8 9
use crate::diff::{ranges, split_lines, text_edits};
9 -
use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType};
10 +
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
11 +
use crate::identity::{self, Domain, Identity, SecretKey, Signature};
10 12
use crate::ids::{ChangeId, NodeId, WorkspaceId};
11 13
use crate::objects::{Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, TreeOp};
12 14
use crate::oplog::{Op, View, SNAPSHOT_SUFFIX};
…
34 unchanged lines
47 49
pub struct Config {
48 50
    pub author_name: String,
49 51
    pub author_email: String,
52 +
    /// The KeyId of the author's identity, once `create_identity` has run.
53 +
    pub author_key: Option<KeyId>,
54 +
    /// `core.sign`: sign every change at commit (`true`) or leave signing to
55 +
    /// push-time seals (`false`, the spec default).
56 +
    pub sign_changes: bool,
50 57
    pub bookmark: String,
51 58
}
52 59
…
3 unchanged lines
56 63
        Config {
57 64
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
58 65
            author_email: std::env::var("OXY_AUTHOR_EMAIL").unwrap_or_else(|_| format!("{user}@localhost")),
66 +
            author_key: None,
67 +
            sign_changes: false,
59 68
            bookmark: "main".into(),
60 69
        }
61 70
    }
62 71
72 +
    /// Set a key as `oxy config` does; returns false for unknown keys.
73 +
    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
74 +
        match key {
75 +
            "author.name" => self.author_name = value.into(),
76 +
            "author.email" => self.author_email = value.into(),
77 +
            "author.key" => {
78 +
                self.author_key = if value.is_empty() {
79 +
                    None
80 +
                } else {
81 +
                    Some(KeyId::from_hex(value).ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?)
82 +
                }
83 +
            }
84 +
            "core.sign" => {
85 +
                self.sign_changes = match value {
86 +
                    "change" => true,
87 +
                    "seal" => false,
88 +
                    _ => return fail("core.sign must be \"change\" or \"seal\""),
89 +
                }
90 +
            }
91 +
            "workspace.bookmark" => self.bookmark = value.into(),
92 +
            _ => return fail(format!("unknown configuration key {key:?}")),
93 +
        }
94 +
        Ok(())
95 +
    }
96 +
63 97
    fn load(path: &Path) -> Result<Config> {
64 98
        let mut c = Config::default_for_user();
65 99
        for line in fs::read_to_string(path)?.lines() {
…
3 unchanged lines
69 103
            }
70 104
            let Some((k, v)) = line.split_once('=') else { continue };
71 105
            let v = v.trim().trim_matches('"').to_string();
72 -
            match k.trim() {
73 -
                "author.name" => c.author_name = v,
74 -
                "author.email" => c.author_email = v,
75 -
                "workspace.bookmark" => c.bookmark = v,
76 -
                _ => {}
77 -
            }
106 +
            let _ = c.set(k.trim(), &v);
78 107
        }
79 108
        Ok(c)
80 109
    }
81 110
82 111
    fn save(&self, path: &Path) -> Result<()> {
83 112
        let text = format!(
84 -
            "author.name = \"{}\"\nauthor.email = \"{}\"\nworkspace.bookmark = \"{}\"\n",
85 -
            self.author_name, self.author_email, self.bookmark
113 +
            "author.name = \"{}\"\nauthor.email = \"{}\"\nauthor.key = \"{}\"\ncore.sign = \"{}\"\nworkspace.bookmark = \"{}\"\n",
114 +
            self.author_name,
115 +
            self.author_email,
116 +
            self.author_key.map(|k| k.to_hex()).unwrap_or_default(),
117 +
            if self.sign_changes { "change" } else { "seal" },
118 +
            self.bookmark
86 119
        );
87 120
        write_atomic(path, text.as_bytes())
88 121
    }
…
56 unchanged lines
145 178
    oxy: PathBuf,
146 179
    store: Store,
147 180
    workspace: WorkspaceId,
181 +
    keys_dir: PathBuf,
148 182
    pub config: Config,
149 183
}
150 184
185 +
/// Where secret keys live: `$OXY_HOME/keys`, else `$XDG_CONFIG_HOME/oxy/keys`,
186 +
/// else `~/.config/oxy/keys`.
187 +
pub fn default_keys_dir() -> PathBuf {
188 +
    if let Ok(h) = std::env::var("OXY_HOME") {
189 +
        return PathBuf::from(h).join("keys");
190 +
    }
191 +
    if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
192 +
        return PathBuf::from(x).join("oxy").join("keys");
193 +
    }
194 +
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
195 +
    PathBuf::from(home).join(".config").join("oxy").join("keys")
196 +
}
197 +
198 +
/// Outcome of verifying the signatures on a change (spec §14.3, §14.4).
199 +
#[derive(Clone, Debug, PartialEq, Eq)]
200 +
pub enum SignatureStatus {
201 +
    Unsigned,
202 +
    Valid { key: KeyId, name: String, email: String },
203 +
    Invalid(String),
204 +
}
205 +
151 206
pub fn is_binary(content: &[u8]) -> bool {
152 207
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
153 208
}
…
14 unchanged lines
168 223
        let workspace = WorkspaceId::random();
169 224
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
170 225
        let store = Store::open(oxy.join("objects"))?;
171 -
        let repo = Repo { root, oxy, store, workspace, config };
226 +
        let repo = Repo { root, oxy, store, workspace, keys_dir: default_keys_dir(), config };
172 227
173 228
        let wc = repo.new_working_copy(vec![], "");
174 229
        let wc_hash = repo.store.put_change(&wc)?;
…
19 unchanged lines
194 249
                let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
195 250
                    .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?;
196 251
                let store = Store::open(oxy.join("objects"))?;
197 -
                return Ok(Repo { root: d.to_path_buf(), oxy, store, workspace, config });
252 +
                return Ok(Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), config });
198 253
            }
199 254
            dir = d.parent();
200 255
        }
…
10 unchanged lines
211 266
        self.workspace
212 267
    }
213 268
269 +
    /// Use a different secret key directory (tests, or `--keys-dir`).
270 +
    pub fn with_keys_dir(mut self, dir: impl Into<PathBuf>) -> Repo {
271 +
        self.keys_dir = dir.into();
272 +
        self
273 +
    }
274 +
275 +
    pub fn save_config(&self) -> Result<()> {
276 +
        self.config.save(&self.oxy.join("config"))
277 +
    }
278 +
279 +
    fn author(&self) -> KeyId {
280 +
        self.config.author_key.unwrap_or(ObjectRef::ZERO)
281 +
    }
282 +
283 +
    // ------------------------------------------------------------ identity
284 +
285 +
    /// Generate a composite key pair, store its identity object, keep the
286 +
    /// secret in the key directory, and make it this repository's author.
287 +
    pub fn create_identity(&mut self, name: &str, email: &str) -> Result<(KeyId, Identity)> {
288 +
        let sk = SecretKey::generate();
289 +
        let id = sk.identity(name, email, Hlc::now().wall_ms);
290 +
        let key = self.store.put_identity(&id)?;
291 +
        fs::create_dir_all(&self.keys_dir)?;
292 +
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
293 +
        write_atomic(&path, &sk.encode(&key))?;
294 +
        #[cfg(unix)]
295 +
        {
296 +
            use std::os::unix::fs::PermissionsExt;
297 +
            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
298 +
        }
299 +
        self.config.author_name = name.into();
300 +
        self.config.author_email = email.into();
301 +
        self.config.author_key = Some(key);
302 +
        self.save_config()?;
303 +
        Ok((key, id))
304 +
    }
305 +
306 +
    /// The secret key for `author.key`, if configured and present on disk.
307 +
    pub fn secret_key(&self) -> Result<Option<(KeyId, SecretKey)>> {
308 +
        let Some(key) = self.config.author_key else { return Ok(None) };
309 +
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
310 +
        match fs::read(&path) {
311 +
            Ok(b) => {
312 +
                let (k, sk) = SecretKey::decode(&b)?;
313 +
                if k != key {
314 +
                    return fail(format!("{} holds a different key than its name", path.display()));
315 +
                }
316 +
                Ok(Some((k, sk)))
317 +
            }
318 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
319 +
            Err(e) => Err(e.into()),
320 +
        }
321 +
    }
322 +
323 +
    pub fn identity(&self, key: &KeyId) -> Result<Identity> {
324 +
        Ok(self.store.get_identity(key)?)
325 +
    }
326 +
327 +
    /// Sign a stored change with the configured secret key.
328 +
    pub fn sign_change(&self, hash: &ChangeHash) -> Result<Signature> {
329 +
        let Some((key, sk)) = self.secret_key()? else {
330 +
            return fail("no secret key: run `oxy identity create` first");
331 +
        };
332 +
        let sig = sk.sign(Domain::Change, &key, hash);
333 +
        self.store.put_signature(hash, &sig)?;
334 +
        Ok(sig)
335 +
    }
336 +
337 +
    /// Check the signatures on a change against the author-or-delegate rule.
338 +
    pub fn verify_change(&self, hash: &ChangeHash) -> Result<SignatureStatus> {
339 +
        let change = self.store.get_change(hash)?;
340 +
        let sigs = self.store.signatures(hash)?;
341 +
        if sigs.is_empty() {
342 +
            return Ok(SignatureStatus::Unsigned);
343 +
        }
344 +
        let mut last_err = String::new();
345 +
        for sig in &sigs {
346 +
            let id = match self.store.get_identity(&sig.key) {
347 +
                Ok(id) => id,
348 +
                Err(_) => {
349 +
                    last_err = format!("unknown key {}", sig.key.short(12));
350 +
                    continue;
351 +
                }
352 +
            };
353 +
            let authorised = sig.key == change.author
354 +
                || self.store.get_identity(&change.author).map(|a| a.delegates.contains(&sig.key)).unwrap_or(false);
355 +
            if !authorised {
356 +
                last_err = format!("key {} is neither the author nor a delegate", sig.key.short(12));
357 +
                continue;
358 +
            }
359 +
            match identity::verify(&id, sig, Domain::Change, hash) {
360 +
                Ok(()) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email }),
361 +
                Err(e) => last_err = e.to_string(),
362 +
            }
363 +
        }
364 +
        Ok(SignatureStatus::Invalid(last_err))
365 +
    }
366 +
214 367
    fn new_working_copy(&self, deps: Vec<ChangeHash>, message: &str) -> Change {
215 368
        let mut c = Change {
216 369
            change_id: ChangeId::random(),
217 370
            deps,
218 -
            author: ObjectRef::ZERO,
371 +
            author: self.author(),
219 372
            hlc: Hlc::now(),
220 373
            message: message.into(),
221 374
            tree_ops: vec![],
…
196 unchanged lines
418 571
        let mut wc = Change {
419 572
            change_id: old_wc.change_id,
420 573
            deps: vec![],
421 -
            author: ObjectRef::ZERO,
574 +
            author: self.author(),
422 575
            hlc: old_wc.hlc,
423 576
            message: old_wc.message.clone(),
424 577
            tree_ops: b.tree_ops,
…
40 unchanged lines
465 618
            }
466 619
            deps.insert(*d);
467 620
        }
621 +
        if self.config.sign_changes && self.secret_key()?.is_none() {
622 +
            return fail("core.sign is \"change\" but no secret key is available for author.key");
623 +
        }
468 624
        let mut change = Change {
469 625
            change_id: snap.wc.change_id,
470 626
            deps: deps.into_iter().collect(),
471 -
            author: ObjectRef::ZERO,
627 +
            author: self.author(),
472 628
            hlc: Hlc::now(),
473 629
            message: message.trim_end().to_string(),
474 630
            tree_ops: snap.wc.tree_ops.clone(),
…
2 unchanged lines
477 633
        };
478 634
        change.normalize();
479 635
        let hash = self.store.put_change(&change)?;
636 +
        if self.config.sign_changes {
637 +
            self.sign_change(&hash)?;
638 +
        }
480 639
481 640
        // New bookmark heads: maximal elements of old heads plus the new change.
482 641
        let old_heads = op.view.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default();
…
164 unchanged lines
647 806
            .changes()
648 807
            .map(|(h, c)| LogEntry { hash: *h, change: c.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) })
649 808
            .collect();
650 -
        entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
809 +
        entries.sort_by(|a, b| b.is_working_copy.cmp(&a.is_working_copy).then(b.change.hlc.cmp(&a.change.hlc)).then(b.hash.cmp(&a.hash)));
651 810
        Ok(entries)
652 811
    }
653 812
…
553 unchanged lines
1207 1366
    }
1208 1367
1209 1368
    #[test]
1369 +
    fn identities_and_signed_commits() {
1370 +
        let dir = tempfile::tempdir().unwrap();
1371 +
        let root = dir.path();
1372 +
        let mut repo = Repo::init(root).unwrap().with_keys_dir(root.join("keys"));
1373 +
        write(root, "a.txt", b"a\n");
1374 +
        let h0 = repo.commit("unsigned", &[]).unwrap();
1375 +
        assert_eq!(repo.verify_change(&h0).unwrap(), SignatureStatus::Unsigned);
1376 +
        assert_eq!(repo.store().get_change(&h0).unwrap().author, ObjectRef::ZERO);
1377 +
1378 +
        let (key, id) = repo.create_identity("Ada", "[email protected]").unwrap();
1379 +
        assert_eq!(id.hash(), key);
1380 +
        assert_eq!(repo.config.author_key, Some(key));
1381 +
        // Config survives reopen; the secret key is found again.
1382 +
        let reopened = Repo::open(root).unwrap().with_keys_dir(root.join("keys"));
1383 +
        assert_eq!(reopened.config.author_key, Some(key));
1384 +
        assert_eq!(reopened.secret_key().unwrap().unwrap().0, key);
1385 +
1386 +
        // Default core.sign = seal: commits carry the author but no signature.
1387 +
        write(root, "a.txt", b"a\nb\n");
1388 +
        let h1 = repo.commit("authored", &[]).unwrap();
1389 +
        assert_eq!(repo.store().get_change(&h1).unwrap().author, key);
1390 +
        assert_eq!(repo.verify_change(&h1).unwrap(), SignatureStatus::Unsigned);
1391 +
        // Signing later works too.
1392 +
        repo.sign_change(&h1).unwrap();
1393 +
        assert!(matches!(repo.verify_change(&h1).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key));
1394 +
1395 +
        repo.config.set("core.sign", "change").unwrap();
1396 +
        repo.save_config().unwrap();
1397 +
        write(root, "a.txt", b"a\nb\nc\n");
1398 +
        let h2 = repo.commit("signed", &[]).unwrap();
1399 +
        assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into() });
1400 +
1401 +
        // A signature by a key that is not the author or a delegate is rejected.
1402 +
        let other = SecretKey::generate();
1403 +
        let other_id = other.identity("Mallory", "[email protected]", 0);
1404 +
        let other_key = repo.store().put_identity(&other_id).unwrap();
1405 +
        let forged = other.sign(Domain::Change, &other_key, &h0);
1406 +
        repo.store().put_signature(&h0, &forged).unwrap();
1407 +
        assert!(matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author")));
1408 +
1409 +
        // Tampering with the stored signature invalidates it.
1410 +
        let mut sig = repo.store().signatures(&h2).unwrap().remove(0);
1411 +
        sig.ed[0] ^= 1;
1412 +
        repo.store().put_signature(&h2, &sig).unwrap();
1413 +
        assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Invalid(_)));
1414 +
1415 +
        // Without the secret key, signing at commit fails cleanly.
1416 +
        let no_keys = Repo::open(root).unwrap().with_keys_dir(root.join("nowhere"));
1417 +
        write(root, "a.txt", b"z\n");
1418 +
        assert!(no_keys.commit("cannot sign", &[]).is_err());
1419 +
    }
1420 +
1421 +
    #[test]
1210 1422
    fn checkout_moves_the_working_copy() {
1211 1423
        let dir = tempfile::tempdir().unwrap();
1212 1424
        let root = dir.path();
…
137 unchanged lines
modified crates/oxy-core/src/store.rs +62 −0
…
2 unchanged lines
3 3
//! Packs come later.
4 4
5 5
use crate::hash::{hash_object, header, ObjectRef, ObjectType};
6 +
use crate::identity::{Identity, Signature};
6 7
use crate::objects::{Change, ObjectError};
7 8
use rand::RngCore;
8 9
use std::fs;
…
108 unchanged lines
117 118
        self.put(ObjectType::Chunk, bytes)
118 119
    }
119 120
121 +
    pub fn put_identity(&self, id: &Identity) -> Result<ObjectRef> {
122 +
        self.put(ObjectType::Identity, &id.encode())
123 +
    }
124 +
125 +
    pub fn get_identity(&self, r: &ObjectRef) -> Result<Identity> {
126 +
        Ok(Identity::decode(&self.get_typed(r, ObjectType::Identity)?)?)
127 +
    }
128 +
129 +
    fn sig_path(&self, r: &ObjectRef) -> PathBuf {
130 +
        let h = r.to_hex();
131 +
        self.dir.join("sigs").join(&h[..2]).join(&h[2..])
132 +
    }
133 +
134 +
    /// Signatures stored alongside object `r` (spec §5.4). Empty if none.
135 +
    pub fn signatures(&self, r: &ObjectRef) -> Result<Vec<Signature>> {
136 +
        match fs::read(self.sig_path(r)) {
137 +
            Ok(b) => Ok(Signature::decode_list(&b)?),
138 +
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(vec![]),
139 +
            Err(e) => Err(e.into()),
140 +
        }
141 +
    }
142 +
143 +
    /// Attach a signature to object `r`, replacing any earlier one by the same key.
144 +
    pub fn put_signature(&self, r: &ObjectRef, sig: &Signature) -> Result<()> {
145 +
        let mut sigs = self.signatures(r)?;
146 +
        sigs.retain(|s| s.key != sig.key);
147 +
        sigs.push(sig.clone());
148 +
        sigs.sort_by(|a, b| a.key.cmp(&b.key));
149 +
        let path = self.sig_path(r);
150 +
        fs::create_dir_all(path.parent().unwrap())?;
151 +
        let mut nonce = [0u8; 8];
152 +
        rand::rngs::OsRng.fill_bytes(&mut nonce);
153 +
        let tmp = self.dir.join(format!("sigs-tmp-{}", hex::encode(nonce)));
154 +
        {
155 +
            let mut f = fs::File::create(&tmp)?;
156 +
            f.write_all(&Signature::encode_list(&sigs))?;
157 +
            f.sync_all()?;
158 +
        }
159 +
        fs::rename(&tmp, &path)?;
160 +
        Ok(())
161 +
    }
162 +
120 163
    /// Every object reference in the store (slow; for gc and tests).
121 164
    pub fn list(&self) -> io::Result<Vec<ObjectRef>> {
122 165
        let mut out = Vec::new();
…
43 unchanged lines
166 209
    }
167 210
168 211
    #[test]
212 +
    fn signatures_are_stored_beside_objects() {
213 +
        use crate::identity::{Domain, SecretKey};
214 +
        let dir = tempfile::tempdir().unwrap();
215 +
        let s = Store::open(dir.path()).unwrap();
216 +
        let r = s.put(ObjectType::Chunk, b"x").unwrap();
217 +
        assert!(s.signatures(&r).unwrap().is_empty());
218 +
        let sk = SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] };
219 +
        let id = sk.identity("a", "b", 0);
220 +
        let key = s.put_identity(&id).unwrap();
221 +
        assert_eq!(s.get_identity(&key).unwrap(), id);
222 +
        let sig = sk.sign(Domain::Change, &key, &r);
223 +
        s.put_signature(&r, &sig).unwrap();
224 +
        s.put_signature(&r, &sig).unwrap();
225 +
        assert_eq!(s.signatures(&r).unwrap(), vec![sig]);
226 +
        // The object itself is untouched by its signatures.
227 +
        assert_eq!(s.get(&r).unwrap().1, b"x");
228 +
    }
229 +
230 +
    #[test]
169 231
    fn change_round_trip_through_store() {
170 232
        let dir = tempfile::tempdir().unwrap();
171 233
        let s = Store::open(dir.path()).unwrap();
…
6 unchanged lines
modified src/main.rs +84 −1
…
1 unchanged lines
2 2
3 3
use anyhow::{bail, Context, Result};
4 4
use clap::{Parser, Subcommand};
5 -
use oxy_core::repo::{ChangeStatus, Repo};
5 +
use oxy_core::repo::{ChangeStatus, Repo, SignatureStatus};
6 6
use std::path::PathBuf;
7 7
8 8
#[derive(Parser)]
…
32 unchanged lines
41 41
    },
42 42
    /// Undo the last operation
43 43
    Undo,
44 +
    /// Manage the signing identity
45 +
    Identity {
46 +
        #[command(subcommand)]
47 +
        cmd: IdentityCmd,
48 +
    },
49 +
    /// Verify the signatures on a change (default: every change in the log)
50 +
    Verify { change: Option<String> },
51 +
    /// Set a repository configuration key (author.name, author.email, author.key, core.sign, workspace.bookmark)
52 +
    Config { key: String, value: String },
44 53
    /// List operations
45 54
    #[command(name = "op")]
46 55
    Op {
…
7 unchanged lines
54 63
    Log,
55 64
}
56 65
66 +
#[derive(Subcommand)]
67 +
enum IdentityCmd {
68 +
    /// Generate a composite Ed25519 + ML-DSA-65 key pair and make it the author
69 +
    Create {
70 +
        #[arg(long)]
71 +
        name: Option<String>,
72 +
        #[arg(long)]
73 +
        email: Option<String>,
74 +
    },
75 +
    /// Show the configured identity
76 +
    Show,
77 +
}
78 +
79 +
fn signature_line(repo: &Repo, h: &oxy_core::hash::ChangeHash) -> Result<String> {
80 +
    Ok(match repo.verify_change(h)? {
81 +
        SignatureStatus::Unsigned => "unsigned".to_string(),
82 +
        SignatureStatus::Valid { key, name, email } => format!("signed by {name} <{email}> key {}", key.short(12)),
83 +
        SignatureStatus::Invalid(why) => format!("INVALID signature: {why}"),
84 +
    })
85 +
}
86 +
57 87
fn main() {
58 88
    match run() {
59 89
        Ok(code) => std::process::exit(code),
…
131 unchanged lines
191 221
            println!("kind:   {:?}", c.kind);
192 222
            println!("date:   {}", when(c.hlc.wall_ms));
193 223
            println!("deps:   {}", c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" "));
224 +
            println!("sig:    {}", signature_line(&repo, &h)?);
194 225
            println!("\n{}\n", if c.message.is_empty() { "(no description)" } else { &c.message });
195 226
            print!("{}", repo.describe_change(&h)?);
196 227
            Ok(0)
…
19 unchanged lines
216 247
            println!("Undid the last operation; now at: {}", op.command);
217 248
            Ok(0)
218 249
        }
250 +
        Cmd::Identity { cmd: IdentityCmd::Create { name, email } } => {
251 +
            let mut repo = open()?;
252 +
            let name = name.unwrap_or_else(|| repo.config.author_name.clone());
253 +
            let email = email.unwrap_or_else(|| repo.config.author_email.clone());
254 +
            let (key, _) = repo.create_identity(&name, &email)?;
255 +
            println!("Created identity {name} <{email}>");
256 +
            println!("KeyId: {key}");
257 +
            println!("Secret key stored under {}", oxy_core::repo::default_keys_dir().display());
258 +
            if !repo.config.sign_changes {
259 +
                println!("Changes will carry this author; run `oxy config core.sign change` to sign each commit.");
260 +
            }
261 +
            Ok(0)
262 +
        }
263 +
        Cmd::Identity { cmd: IdentityCmd::Show } => {
264 +
            let repo = open()?;
265 +
            match repo.config.author_key {
266 +
                None => println!("No identity configured; run `oxy identity create`."),
267 +
                Some(key) => {
268 +
                    let id = repo.identity(&key)?;
269 +
                    println!("{} <{}>", id.name, id.email);
270 +
                    println!("KeyId:   {key}");
271 +
                    println!("Created: {}", when(id.created));
272 +
                    println!("Secret:  {}", if repo.secret_key()?.is_some() { "available" } else { "NOT FOUND in key directory" });
273 +
                    println!("Signing: {}", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" });
274 +
                }
275 +
            }
276 +
            Ok(0)
277 +
        }
278 +
        Cmd::Verify { change } => {
279 +
            let repo = open()?;
280 +
            let hashes: Vec<oxy_core::hash::ChangeHash> = match change {
281 +
                Some(c) => vec![repo.resolve_change(&c)?],
282 +
                None => repo.log()?.into_iter().filter(|e| !e.is_working_copy).map(|e| e.hash).collect(),
283 +
            };
284 +
            let mut bad = 0;
285 +
            for h in hashes {
286 +
                let c = repo.store().get_change(&h)?;
287 +
                let line = signature_line(&repo, &h)?;
288 +
                if line.starts_with("INVALID") {
289 +
                    bad += 1;
290 +
                }
291 +
                println!("{} #{}  {}", short_id(&c), h.short(12), line);
292 +
            }
293 +
            Ok(if bad > 0 { 1 } else { 0 })
294 +
        }
295 +
        Cmd::Config { key, value } => {
296 +
            let mut repo = open()?;
297 +
            repo.config.set(&key, &value)?;
298 +
            repo.save_config()?;
299 +
            println!("{key} = {value}");
300 +
            Ok(0)
301 +
        }
219 302
        Cmd::Op { cmd: OpCmd::Log } => {
220 303
            let repo = open()?;
221 304
            for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
…
14 unchanged lines