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 |