Skip to main content

coven_core/
keys.rs

1use ed25519_dalek::{Signer, SigningKey, Verifier};
2use rand::RngCore;
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6pub const SIGN_PUBLICKEYBYTES: usize = ed25519_dalek::PUBLIC_KEY_LENGTH;
7pub const SIGN_SECRETKEYBYTES: usize = ed25519_dalek::KEYPAIR_LENGTH;
8pub const SIGN_BYTES: usize = ed25519_dalek::SIGNATURE_LENGTH;
9pub const CURVE25519_PUBLICKEYBYTES: usize = crypto_box::KEY_SIZE;
10pub const CURVE25519_SECRETKEYBYTES: usize = crypto_box::KEY_SIZE;
11pub const SEALBYTES: usize = crypto_box::SEALBYTES;
12
13#[derive(Error, Debug)]
14pub enum KeyError {
15    #[error("Crypto error: {0}")]
16    Crypto(String),
17    #[error("Key persistence error: {0}")]
18    Persistence(String),
19    #[error(
20        "no keyring store is installed; the host must install the platform keyring store at startup (set_keyring_service) before any key operation"
21    )]
22    StoreNotInstalled,
23    #[error(
24        "no bundled keyring store exists for this target; the host must supply one via keyring_core::set_default_store before registering the keyring service"
25    )]
26    UnsupportedKeyringPlatform,
27    #[error(
28        "no keyring service is registered; the host must call set_keyring_service at startup before any key operation"
29    )]
30    ServiceNotRegistered,
31    #[error(
32        "no identity is established for this store; create, join, or restore the store first — each establishes this store's identity as part of what it does"
33    )]
34    NoDeviceIdentity,
35    #[error(
36        "this store's identity is already established under a different key (existing {existing_pubkey_hex}, attempted import {imported_pubkey_hex}); importing a different identity would strand this store's membership entries"
37    )]
38    IdentityMismatch {
39        existing_pubkey_hex: String,
40        imported_pubkey_hex: String,
41    },
42    #[error(
43        "no pending identity is held for join request {request_public_key_hex}; the request may have already completed, been abandoned, or never existed"
44    )]
45    NoPendingIdentity { request_public_key_hex: String },
46    #[error("invalid host secret name {name:?}: {reason}")]
47    InvalidSecretName { name: String, reason: String },
48    /// Adopting a rotated store key found, under the write lock, that the
49    /// incoming keyring adds nothing to the live one — a concurrent op already
50    /// adopted a keyring that covers it. Failing loud keeps the delayed apply
51    /// from regressing the seal key or custody.
52    #[error("stale key rotation: the incoming keyring does not extend the live one")]
53    StaleKeyRotation,
54    /// The OS refused a Keychain data-protection-store operation with
55    /// `errSecMissingEntitlement` (OSStatus -34018). This is not "the binary
56    /// isn't signed" — an ad-hoc or Development-signed binary with no
57    /// `keychain-access-groups` entitlement at all also gets -34018, and a
58    /// signed binary that *does* carry that entitlement with no provisioning
59    /// profile behind it is killed by the kernel at launch instead. The fix is
60    /// a team-prefixed `keychain-access-groups` entitlement backed by an
61    /// embedded provisioning profile — in Xcode, set `DEVELOPMENT_TEAM` so
62    /// automatic signing fetches and embeds one. A build with no team must
63    /// omit the entitlement entirely, which means it also has no access to
64    /// the data-protection keychain and will hit this error on first use.
65    #[error(
66        "the OS refused this keychain operation with errSecMissingEntitlement \
67         (OSStatus -34018): the process has no team-prefixed keychain-access-groups \
68         entitlement backed by an embedded provisioning profile; set DEVELOPMENT_TEAM \
69         so Xcode's automatic signing fetches and embeds one (a keychain-access-groups \
70         entitlement present WITHOUT a provisioning profile is a different failure: the \
71         process is killed by the kernel at launch, not this error) — a build with no \
72         team must omit the entitlement and will hit this same error on first key use"
73    )]
74    MissingKeychainEntitlement,
75}
76
77/// Credentials for the cloud home, stored as a single JSON keyring entry.
78///
79/// `Debug` is hand-written so the S3 `secret_key` and the OAuth `token_json`
80/// print as `<redacted>` — `{:?}` in an error path cannot leak them.
81#[derive(Clone, Serialize, Deserialize)]
82pub enum CloudHomeCredentials {
83    /// S3-compatible providers: access key + secret key.
84    S3 {
85        access_key: String,
86        secret_key: String,
87    },
88    /// Consumer cloud providers (Google Drive, Dropbox, OneDrive): OAuth token JSON.
89    OAuth { token_json: String },
90    /// iCloud: no credentials needed (macOS handles auth).
91    None,
92}
93
94impl std::fmt::Debug for CloudHomeCredentials {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            CloudHomeCredentials::S3 {
98                access_key,
99                secret_key: _,
100            } => f
101                .debug_struct("S3")
102                .field("access_key", access_key)
103                .field("secret_key", &"<redacted>")
104                .finish(),
105            CloudHomeCredentials::OAuth { token_json: _ } => f
106                .debug_struct("OAuth")
107                .field("token_json", &"<redacted>")
108                .finish(),
109            CloudHomeCredentials::None => f.write_str("None"),
110        }
111    }
112}
113
114/// Ed25519 keypair for signing changesets and membership changes.
115/// The same seed can derive an X25519 keypair for key wrapping.
116///
117/// One keypair is generated per (store, device) pair: a device holds a
118/// distinct identity in each store it belongs to, so a key scoped to one
119/// store carries no authority in another, and the same device's pubkey does
120/// not appear in more than one store's membership chain.
121#[derive(Clone)]
122pub struct UserKeypair {
123    signing_key: SigningKey,
124}
125
126impl UserKeypair {
127    /// Generate a new random Ed25519 keypair. The unmanaged primitive behind
128    /// every identity-establishing act — creating, joining, or restoring a
129    /// store; also lets host code (and its tests) mint an identity directly.
130    pub fn generate() -> Self {
131        let mut seed = [0u8; 32];
132        rand::rng().fill_bytes(&mut seed);
133        let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
134        Self { signing_key }
135    }
136
137    /// Reconstruct a keypair from its 64-byte Ed25519 signing key (seed + public),
138    /// deriving the public key from it and validating that the bytes are a real
139    /// keypair. This is the single place stored signing-key bytes become a
140    /// `UserKeypair`, so a torn or corrupt signing key fails at the persistence
141    /// boundary.
142    pub fn from_signing_key_bytes(
143        signing_key: &[u8; SIGN_SECRETKEYBYTES],
144    ) -> Result<Self, KeyError> {
145        let signing_key = ed25519_dalek::SigningKey::from_keypair_bytes(signing_key)
146            .map_err(|e| KeyError::Crypto(format!("Invalid signing key bytes: {e}")))?;
147        Ok(Self { signing_key })
148    }
149
150    pub fn public_key(&self) -> [u8; SIGN_PUBLICKEYBYTES] {
151        self.signing_key.verifying_key().to_bytes()
152    }
153
154    pub fn to_keypair_bytes(&self) -> [u8; SIGN_SECRETKEYBYTES] {
155        self.signing_key.to_keypair_bytes()
156    }
157
158    pub(crate) fn derive_signing_key(&self, domain: &[u8], context: &[u8]) -> Self {
159        use sha2::{Digest, Sha256};
160
161        let mut derivation = Sha256::new();
162        derivation.update(domain);
163        derivation.update(self.signing_key.to_bytes());
164        derivation.update(context);
165        let seed: [u8; 32] = derivation.finalize().into();
166        Self {
167            signing_key: SigningKey::from_bytes(&seed),
168        }
169    }
170
171    /// Sign a message, returning a 64-byte detached signature.
172    pub fn sign(&self, message: &[u8]) -> [u8; SIGN_BYTES] {
173        self.signing_key.sign(message).to_bytes()
174    }
175
176    /// Derive the X25519 secret key from this Ed25519 signing key.
177    pub fn to_x25519_secret_key(&self) -> [u8; CURVE25519_SECRETKEYBYTES] {
178        self.signing_key.to_scalar_bytes()
179    }
180
181    /// Derive the X25519 public key from this Ed25519 public key.
182    pub fn to_x25519_public_key(&self) -> [u8; CURVE25519_PUBLICKEYBYTES] {
183        self.signing_key.verifying_key().to_montgomery().to_bytes()
184    }
185}
186
187/// Hex-encode the public key attached to `keypair`.
188pub fn public_key_hex(keypair: &UserKeypair) -> String {
189    hex::encode(keypair.public_key())
190}
191
192/// Sign `message` and return the hex-encoded public key and detached signature.
193pub fn sign_hex(keypair: &UserKeypair, message: &[u8]) -> (String, String) {
194    (public_key_hex(keypair), hex::encode(keypair.sign(message)))
195}
196
197/// Verify a detached Ed25519 signature against a public key.
198pub fn verify_signature(
199    signature: &[u8; SIGN_BYTES],
200    message: &[u8],
201    public_key: &[u8; SIGN_PUBLICKEYBYTES],
202) -> bool {
203    let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(public_key) else {
204        return false;
205    };
206    let sig = ed25519_dalek::Signature::from_bytes(signature);
207    vk.verify(message, &sig).is_ok()
208}
209
210/// Verify a hex-encoded detached Ed25519 signature (`sig_hex`) over `message`
211/// against a hex-encoded public key (`pk_hex`). Malformed hex, a wrong-length key
212/// or signature, or a non-matching signature all fail closed (false). The shared
213/// hex front-end of [`verify_signature`], used by signed Store objects and
214/// membership entries so the decode-and-verify path lives in one place.
215pub fn verify_signature_hex(pk_hex: &str, sig_hex: &str, message: &[u8]) -> bool {
216    let Ok(pk_bytes) = hex::decode(pk_hex) else {
217        return false;
218    };
219    let Ok(sig_bytes) = hex::decode(sig_hex) else {
220        return false;
221    };
222    let Ok(pk): Result<[u8; SIGN_PUBLICKEYBYTES], _> = pk_bytes.try_into() else {
223        return false;
224    };
225    let Ok(sig): Result<[u8; SIGN_BYTES], _> = sig_bytes.try_into() else {
226        return false;
227    };
228    verify_signature(&sig, message, &pk)
229}
230
231/// Encrypt a message to a recipient's X25519 public key using a sealed box.
232/// The sender is anonymous -- only the recipient can decrypt.
233pub fn seal_box_encrypt(
234    message: &[u8],
235    recipient_x25519_pk: &[u8; CURVE25519_PUBLICKEYBYTES],
236) -> Vec<u8> {
237    crypto_box::PublicKey::from(*recipient_x25519_pk)
238        .seal(&mut crypto_box::aead::OsRng, message)
239        .expect("sealed box encryption should not fail")
240}
241
242/// Decrypt a sealed box using the recipient's X25519 secret key.
243/// `crypto_box::SecretKey::unseal` derives the recipient public key internally.
244pub fn seal_box_decrypt(
245    ciphertext: &[u8],
246    recipient_x25519_sk: &[u8; CURVE25519_SECRETKEYBYTES],
247) -> Result<Vec<u8>, KeyError> {
248    crypto_box::SecretKey::from(*recipient_x25519_sk)
249        .unseal(ciphertext)
250        .map_err(|_| {
251            KeyError::Crypto("Sealed box decryption failed (wrong key or tampered)".to_string())
252        })
253}
254
255/// Convert an Ed25519 public key to an X25519 public key.
256///
257/// This is used when we only have a remote user's Ed25519 public key (hex string)
258/// and need to encrypt something to them via sealed box. The `UserKeypair` methods
259/// handle the local case; this handles the remote case.
260pub fn ed25519_to_x25519_public_key(
261    ed25519_pk: &[u8; SIGN_PUBLICKEYBYTES],
262) -> Result<[u8; CURVE25519_PUBLICKEYBYTES], KeyError> {
263    let vk = ed25519_dalek::VerifyingKey::from_bytes(ed25519_pk)
264        .map_err(|_| KeyError::Crypto("invalid Ed25519 public key point".to_string()))?;
265    if vk.is_weak() {
266        return Err(KeyError::Crypto(
267            "weak Ed25519 public key point cannot identify a recipient".to_string(),
268        ));
269    }
270    Ok(vk.to_montgomery().to_bytes())
271}
272
273/// Derive an X25519 shared secret after rejecting public inputs that cannot
274/// identify a peer. Low-order public keys produce the all-zero shared secret;
275/// that result is never usable as recipient identity material.
276pub fn x25519_shared_secret(
277    local_secret: [u8; CURVE25519_SECRETKEYBYTES],
278    peer_public: [u8; CURVE25519_PUBLICKEYBYTES],
279) -> Result<[u8; CURVE25519_PUBLICKEYBYTES], KeyError> {
280    if peer_public == [0; CURVE25519_PUBLICKEYBYTES] {
281        return Err(KeyError::Crypto(
282            "all-zero X25519 public key cannot identify a recipient".to_string(),
283        ));
284    }
285    let shared = x25519_dalek::x25519(local_secret, peer_public);
286    if shared == [0; CURVE25519_PUBLICKEYBYTES] {
287        return Err(KeyError::Crypto(
288            "all-zero X25519 shared secret cannot identify a recipient".to_string(),
289        ));
290    }
291    Ok(shared)
292}
293
294use crate::encryption::MasterKeyring;
295
296/// A store's master keyring's custody: who unlocks it, where a newly
297/// established or rotated one is written, and how it is removed. Implemented
298/// once per protection policy (the OS keyring, a passphrase-wrapped file, an
299/// in-memory session value, or a host's own).
300pub trait MasterKeyCustody: Send + Sync {
301    /// The store's master keyring for this session. `Ok(None)` means the store
302    /// has never had one established (a fresh store before create/join) —
303    /// distinct from a failure to produce one (wrong passphrase, unreadable
304    /// backing store), which is `Err`.
305    fn unlock(&self) -> Result<Option<MasterKeyring>, KeyError>;
306
307    /// Protect and store `keyring`, replacing whatever is stored. Serves both
308    /// establishment (create/join/restore) and rotation re-protection (member
309    /// removal, the per-cycle refresh adoption). Idempotent.
310    fn persist(&self, keyring: &MasterKeyring) -> Result<(), KeyError>;
311
312    /// Remove the stored keyring. `Ok` when nothing was stored.
313    fn forget(&self) -> Result<(), KeyError>;
314}
315
316/// A device's signing identity's custody FOR ONE STORE: who unlocks it,
317/// where a newly established one is written, and how it is removed. The
318/// signing-key sibling of [`MasterKeyCustody`], same three-method shape and
319/// the same per-store selection, over [`UserKeypair`] instead of a store's
320/// master keyring.
321pub trait DeviceIdentityCustody: Send + Sync {
322    /// This store's established signing identity. `Ok(None)` means none has
323    /// ever been established — distinct from a failure to produce one (wrong
324    /// passphrase, unreadable backing store), which is `Err`.
325    fn unlock(&self) -> Result<Option<UserKeypair>, KeyError>;
326
327    /// Protect and store `keypair`, replacing whatever is stored. Idempotent.
328    fn persist(&self, keypair: &UserKeypair) -> Result<(), KeyError>;
329
330    /// Remove the stored identity. `Ok` when nothing was stored.
331    fn forget(&self) -> Result<(), KeyError>;
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn keypair_generation_produces_valid_keys() {
340        let kp = UserKeypair::generate();
341
342        assert_eq!(kp.to_keypair_bytes().len(), SIGN_SECRETKEYBYTES);
343        assert_eq!(kp.public_key().len(), SIGN_PUBLICKEYBYTES);
344
345        // Keys should not be all zeros (astronomically unlikely)
346        assert!(kp.to_keypair_bytes().iter().any(|&b| b != 0));
347        assert!(kp.public_key().iter().any(|&b| b != 0));
348    }
349
350    #[test]
351    fn two_keypairs_are_distinct() {
352        let kp1 = UserKeypair::generate();
353        let kp2 = UserKeypair::generate();
354        assert_ne!(kp1.public_key(), kp2.public_key());
355    }
356
357    #[test]
358    fn sign_and_verify_roundtrip() {
359        let kp = UserKeypair::generate();
360        let message = b"changeset payload";
361
362        let sig = kp.sign(message);
363        assert!(verify_signature(&sig, message, &kp.public_key()));
364    }
365
366    #[test]
367    fn keypair_bytes_roundtrip_preserves_signing_identity() {
368        let kp = UserKeypair::generate();
369        let keypair_bytes = kp.to_keypair_bytes();
370        let restored =
371            UserKeypair::from_signing_key_bytes(&keypair_bytes).expect("stored keypair bytes");
372        let message = b"persisted identity";
373
374        assert_eq!(restored.to_keypair_bytes(), keypair_bytes);
375        assert_eq!(restored.public_key(), kp.public_key());
376        assert!(verify_signature(
377            &restored.sign(message),
378            message,
379            &restored.public_key()
380        ));
381    }
382
383    #[test]
384    fn sign_hex_returns_public_key_and_valid_signature() {
385        let kp = UserKeypair::generate();
386        let message = b"changeset payload";
387
388        let (pk_hex, sig_hex) = sign_hex(&kp, message);
389
390        assert_eq!(pk_hex, public_key_hex(&kp));
391        assert!(verify_signature_hex(&pk_hex, &sig_hex, message));
392    }
393
394    #[test]
395    fn verify_rejects_wrong_message() {
396        let kp = UserKeypair::generate();
397        let sig = kp.sign(b"original");
398        assert!(!verify_signature(&sig, b"tampered", &kp.public_key()));
399    }
400
401    #[test]
402    fn verify_rejects_wrong_key() {
403        let kp1 = UserKeypair::generate();
404        let kp2 = UserKeypair::generate();
405        let sig = kp1.sign(b"message");
406        assert!(!verify_signature(&sig, b"message", &kp2.public_key()));
407    }
408
409    #[test]
410    fn sign_empty_message() {
411        let kp = UserKeypair::generate();
412        let sig = kp.sign(b"");
413        assert!(verify_signature(&sig, b"", &kp.public_key()));
414    }
415
416    #[test]
417    fn ed25519_to_x25519_conversion() {
418        let kp = UserKeypair::generate();
419        let x_sk = kp.to_x25519_secret_key();
420        let x_pk = kp.to_x25519_public_key();
421        let converted = ed25519_to_x25519_public_key(&kp.public_key()).unwrap();
422
423        // Should produce non-zero 32-byte keys
424        assert_eq!(x_sk.len(), 32);
425        assert_eq!(x_pk.len(), 32);
426        assert!(x_sk.iter().any(|&b| b != 0));
427        assert!(x_pk.iter().any(|&b| b != 0));
428        assert_eq!(converted, x_pk);
429    }
430
431    #[test]
432    fn ed25519_to_x25519_rejects_off_curve_bytes() {
433        let mut bytes = [0u8; SIGN_PUBLICKEYBYTES];
434        bytes[0] = 2;
435
436        let error = ed25519_to_x25519_public_key(&bytes).expect_err("invalid point fails");
437
438        assert!(matches!(error, KeyError::Crypto(_)));
439        assert!(error
440            .to_string()
441            .contains("invalid Ed25519 public key point"));
442    }
443
444    #[test]
445    fn ed25519_to_x25519_rejects_the_identity_point() {
446        let mut identity = [0; SIGN_PUBLICKEYBYTES];
447        identity[0] = 1;
448
449        let error = ed25519_to_x25519_public_key(&identity)
450            .expect_err("a weak recipient point must not produce a shared key");
451
452        assert!(matches!(error, KeyError::Crypto(_)));
453    }
454
455    #[test]
456    fn x25519_shared_secret_rejects_the_all_zero_public_key() {
457        let local = UserKeypair::generate();
458
459        let error =
460            x25519_shared_secret(local.to_x25519_secret_key(), [0; CURVE25519_PUBLICKEYBYTES])
461                .expect_err("an all-zero public key must not produce recipient identity material");
462
463        assert!(matches!(error, KeyError::Crypto(_)));
464    }
465
466    #[test]
467    fn x25519_shared_secret_rejects_a_nonzero_low_order_public_key() {
468        let local = UserKeypair::generate();
469        let mut low_order = [0; CURVE25519_PUBLICKEYBYTES];
470        low_order[0] = 1;
471
472        let error = x25519_shared_secret(local.to_x25519_secret_key(), low_order)
473            .expect_err("a low-order public key must not produce recipient identity material");
474
475        assert!(matches!(error, KeyError::Crypto(_)));
476    }
477
478    #[test]
479    fn ed25519_to_x25519_is_deterministic() {
480        let kp = UserKeypair::generate();
481        let x_sk1 = kp.to_x25519_secret_key();
482        let x_sk2 = kp.to_x25519_secret_key();
483        assert_eq!(x_sk1, x_sk2);
484    }
485
486    #[test]
487    fn sealed_box_roundtrip() {
488        let kp = UserKeypair::generate();
489        let x_pk = kp.to_x25519_public_key();
490        let x_sk = kp.to_x25519_secret_key();
491
492        let plaintext = b"store encryption key material";
493        let ciphertext = seal_box_encrypt(plaintext, &x_pk);
494
495        assert_eq!(ciphertext.len(), plaintext.len() + SEALBYTES);
496
497        let decrypted = seal_box_decrypt(&ciphertext, &x_sk).unwrap();
498        assert_eq!(decrypted, plaintext);
499    }
500
501    #[test]
502    fn sealed_box_wrong_key_fails() {
503        let kp1 = UserKeypair::generate();
504        let kp2 = UserKeypair::generate();
505
506        let ciphertext = seal_box_encrypt(b"secret", &kp1.to_x25519_public_key());
507
508        let result = seal_box_decrypt(&ciphertext, &kp2.to_x25519_secret_key());
509        assert!(result.is_err());
510    }
511
512    #[test]
513    fn sealed_box_empty_message() {
514        let kp = UserKeypair::generate();
515        let x_pk = kp.to_x25519_public_key();
516        let x_sk = kp.to_x25519_secret_key();
517
518        let ciphertext = seal_box_encrypt(b"", &x_pk);
519        let decrypted = seal_box_decrypt(&ciphertext, &x_sk).unwrap();
520        assert!(decrypted.is_empty());
521    }
522
523    #[test]
524    fn sealed_box_too_short_ciphertext() {
525        let kp = UserKeypair::generate();
526        let result = seal_box_decrypt(&[0u8; 10], &kp.to_x25519_secret_key());
527        assert!(result.is_err());
528    }
529
530    /// Pins the actionable content of `MissingKeychainEntitlement`'s message:
531    /// the real OS error and the real fix (a team-prefixed
532    /// `keychain-access-groups` entitlement backed by a provisioning
533    /// profile), not the wrong "must be signed" advice this replaced.
534    #[test]
535    fn missing_keychain_entitlement_message_names_the_real_error_and_fix() {
536        let message = KeyError::MissingKeychainEntitlement.to_string();
537
538        assert!(message.contains("-34018"), "{message}");
539        assert!(message.contains("errSecMissingEntitlement"), "{message}");
540        assert!(message.contains("keychain-access-groups"), "{message}");
541        assert!(message.contains("provisioning profile"), "{message}");
542        assert!(message.contains("DEVELOPMENT_TEAM"), "{message}");
543        assert!(
544            !message.contains("must be signed"),
545            "a bare 'signed binary' is the wrong fix and must not be implied: {message}"
546        );
547    }
548
549    #[test]
550    fn credentials_debug_redacts_s3_secret_and_oauth_token() {
551        let s3 = CloudHomeCredentials::S3 {
552            access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
553            secret_key: "s3-secret-value-do-not-print".to_string(),
554        };
555        let debug = format!("{s3:?}");
556        assert!(debug.contains("<redacted>"), "{debug}");
557        assert!(debug.contains("AKIAIOSFODNN7EXAMPLE"), "{debug}");
558        assert!(
559            !debug.contains("s3-secret-value-do-not-print"),
560            "S3 secret key leaked: {debug}"
561        );
562
563        let oauth = CloudHomeCredentials::OAuth {
564            token_json: "{\"access_token\":\"oauth-token-do-not-print\"}".to_string(),
565        };
566        let debug = format!("{oauth:?}");
567        assert!(debug.contains("<redacted>"), "{debug}");
568        assert!(
569            !debug.contains("oauth-token-do-not-print"),
570            "OAuth token leaked: {debug}"
571        );
572    }
573}