Skip to main content

coven_keys/keys/
core.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;
11#[cfg(test)]
12pub(crate) const SEALBYTES: usize = crypto_box::SEALBYTES;
13
14#[derive(Error, Debug)]
15pub enum KeyError {
16    #[error("file error: {0}")]
17    File(#[from] coven_foundation::atomic_file::FileError),
18    #[error("keyring operation failed: {0}")]
19    Keyring(#[source] keyring_core::Error),
20    #[error("failed to start the keyring worker: {0}")]
21    KeyringWorkerStart(#[source] std::io::Error),
22    #[error("the keyring worker stopped while attempting to {operation}")]
23    KeyringWorkerStopped { operation: &'static str },
24    #[error("key custody {operation} failed: {source}")]
25    Custody {
26        operation: &'static str,
27        #[source]
28        source: Box<dyn std::error::Error + Send + Sync + 'static>,
29    },
30    #[error("{operation}: {source}")]
31    Json {
32        operation: &'static str,
33        #[source]
34        source: serde_json::Error,
35    },
36    #[error("{subject} is not valid hexadecimal: {source}")]
37    Hex {
38        subject: &'static str,
39        #[source]
40        source: hex::FromHexError,
41    },
42    #[error("{subject} has length {actual}, expected {expected}")]
43    InvalidLength {
44        subject: &'static str,
45        expected: usize,
46        actual: usize,
47    },
48    #[error("stored signing key is invalid: {0}")]
49    SigningKey(#[source] ed25519_dalek::SignatureError),
50    #[error("key encryption failed: {0}")]
51    Encryption(#[from] crate::encryption::EncryptionError),
52    #[error("decrypted key material is not UTF-8: {0}")]
53    Utf8(#[from] std::string::FromUtf8Error),
54    #[error("base64-encoded key material is invalid: {0}")]
55    Base64(#[from] base64::DecodeError),
56    #[error("passphrase key derivation {operation} failed: {source}")]
57    PassphraseKdf {
58        operation: &'static str,
59        #[source]
60        source: Box<dyn std::error::Error + Send + Sync + 'static>,
61    },
62    #[error("passphrase envelope is version {actual}, not this build's version {expected}; update to a build that understands it")]
63    UnsupportedPassphraseEnvelopeVersion { actual: u32, expected: u32 },
64    #[error("passphrase envelope names KDF {actual:?}, but this build only supports {expected:?}")]
65    UnsupportedPassphraseKdf {
66        actual: String,
67        expected: &'static str,
68    },
69    #[error("passphrase envelope's Argon2id {parameter} ({actual}) is below the required floor ({minimum})")]
70    WeakArgon2Parameter {
71        parameter: &'static str,
72        actual: u32,
73        minimum: u32,
74    },
75    #[error("envelope decryption failed: wrong passphrase or a corrupt file")]
76    PassphraseEnvelopeDecryption,
77    #[error("sealed box decryption failed (wrong key or tampered)")]
78    SealedBoxDecryption,
79    #[error("invalid Ed25519 public key point")]
80    InvalidEd25519PublicKey,
81    #[error("weak Ed25519 public key point cannot identify a recipient")]
82    WeakEd25519PublicKey,
83    #[error("all-zero X25519 public key cannot identify a recipient")]
84    AllZeroX25519PublicKey,
85    #[error("all-zero X25519 shared secret cannot identify a recipient")]
86    AllZeroX25519SharedSecret,
87    #[error("cannot rotate the key of a plaintext cloud home")]
88    PlaintextCloudKeyRotation,
89    #[error("live keyring changed without retaining an adopted rotation")]
90    UnretainedKeyRotation,
91    #[error("keyring service is already registered as {registered:?}; cannot re-register as {requested:?}")]
92    ServiceAlreadyRegistered {
93        registered: String,
94        requested: String,
95    },
96    #[error("keyring entry {account} is present but empty (corrupt)")]
97    EmptyKeyringEntry { account: String },
98    #[error("cannot {operation} cloud-home credentials after their setup was rolled back")]
99    CloudCredentialsRolledBack { operation: &'static str },
100    #[error("cloud-home credentials belong to a replaced provider connection")]
101    CloudCredentialsSuperseded,
102    #[error("cannot {operation} a master key after its setup was rolled back")]
103    MasterKeySetupRolledBack { operation: &'static str },
104    #[error("Apple keyring entry was not constructed by the protected-data store")]
105    UnexpectedAppleKeyringEntry,
106    #[cfg(any(test, feature = "test-utils"))]
107    #[error("test keyring entry was not constructed by the mock store")]
108    UnexpectedTestKeyringEntry,
109    #[error(
110        "no keyring store is installed; the host must install the platform keyring store at startup (set_keyring_service) before any key operation"
111    )]
112    StoreNotInstalled,
113    #[error(
114        "no bundled keyring store exists for this target; the host must supply one via keyring_core::set_default_store before registering the keyring service"
115    )]
116    UnsupportedKeyringPlatform,
117    #[error(
118        "no keyring service is registered; the host must call set_keyring_service at startup before any key operation"
119    )]
120    ServiceNotRegistered,
121    #[error(
122        "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"
123    )]
124    NoDeviceIdentity,
125    #[error(
126        "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"
127    )]
128    IdentityMismatch {
129        existing_pubkey_hex: String,
130        imported_pubkey_hex: String,
131    },
132    #[error(
133        "no pending identity is held for device pairing {pending_public_key_hex}; the pairing may have already completed, been abandoned, or never existed"
134    )]
135    NoPendingIdentity { pending_public_key_hex: String },
136    #[error("invalid host secret name {name:?}: {reason}")]
137    InvalidSecretName { name: String, reason: String },
138    /// The OS refused a Keychain data-protection-store operation with
139    /// `errSecMissingEntitlement` (OSStatus -34018). This is not "the binary
140    /// isn't signed" — an ad-hoc or Development-signed binary with no
141    /// `keychain-access-groups` entitlement at all also gets -34018, and a
142    /// signed binary that *does* carry that entitlement with no provisioning
143    /// profile behind it is killed by the kernel at launch instead. The fix is
144    /// a team-prefixed `keychain-access-groups` entitlement backed by an
145    /// embedded provisioning profile — in Xcode, set `DEVELOPMENT_TEAM` so
146    /// automatic signing fetches and embeds one. A build with no team must
147    /// omit the entitlement entirely, which means it also has no access to
148    /// the data-protection keychain and will hit this error on first use.
149    #[error(
150        "the OS refused this keychain operation with errSecMissingEntitlement \
151         (OSStatus -34018): the process has no team-prefixed keychain-access-groups \
152         entitlement backed by an embedded provisioning profile; set DEVELOPMENT_TEAM \
153         so Xcode's automatic signing fetches and embeds one (a keychain-access-groups \
154         entitlement present WITHOUT a provisioning profile is a different failure: the \
155         process is killed by the kernel at launch, not this error) — a build with no \
156         team must omit the entitlement and will hit this same error on first key use"
157    )]
158    MissingKeychainEntitlement,
159    /// The OS refused this keychain operation with `errSecInteractionNotAllowed`
160    /// (OSStatus -25308): the keychain is locked, the display is asleep, or the
161    /// login session cannot show UI. Nothing is wrong with the entry or with
162    /// this process's entitlements — the same operation succeeds once the
163    /// session unlocks, so a caller that needs the key should say so and try
164    /// again rather than treat the store as broken or the key as absent.
165    #[error(
166        "the OS refused this keychain operation with errSecInteractionNotAllowed \
167         (OSStatus -25308): the keychain is locked, the display is asleep, or this \
168         login session cannot show UI — the same operation succeeds once the session \
169         unlocks, so retry it then rather than treating the key as missing"
170    )]
171    KeychainTemporarilyUnavailable,
172}
173
174/// Credentials for the cloud home, stored as a single JSON keyring entry.
175///
176/// `Debug` is hand-written so the S3 `secret_key` and the OAuth tokens
177/// print as `<redacted>` — `{:?}` in an error path cannot leak them.
178#[derive(Clone, Serialize, Deserialize)]
179pub enum CloudHomeCredentials {
180    /// S3-compatible providers: access key + secret key.
181    S3 {
182        access_key: String,
183        secret_key: String,
184    },
185    /// Consumer cloud providers (Google Drive, Dropbox, OneDrive).
186    OAuth { tokens: crate::keys::OAuthTokens },
187}
188
189impl std::fmt::Debug for CloudHomeCredentials {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        match self {
192            CloudHomeCredentials::S3 {
193                access_key,
194                secret_key: _,
195            } => f
196                .debug_struct("S3")
197                .field("access_key", access_key)
198                .field("secret_key", &"<redacted>")
199                .finish(),
200            CloudHomeCredentials::OAuth { tokens: _ } => f
201                .debug_struct("OAuth")
202                .field("tokens", &"<redacted>")
203                .finish(),
204        }
205    }
206}
207
208/// Ed25519 keypair for signing changesets and membership changes.
209/// The same seed can derive an X25519 keypair for key wrapping.
210///
211/// One keypair is generated per (store, device) pair: a device holds a
212/// distinct identity in each store it belongs to, so a key scoped to one
213/// store carries no authority in another, and the same device's pubkey does
214/// not appear in more than one store's membership chain.
215#[derive(Clone)]
216pub struct UserKeypair {
217    signing_key: SigningKey,
218}
219
220/// A retained capability that can sign as one device without exposing its key.
221pub trait DeviceSigningAuthority: Send + Sync {
222    fn public_key_hex(&self) -> String;
223    fn sign(&self, message: &[u8]) -> [u8; SIGN_BYTES];
224}
225
226/// A retained capability that acts as one Store identity without exposing its key.
227pub trait IdentityKeyAuthority: Send + Sync {
228    fn public_key(&self) -> [u8; SIGN_PUBLICKEYBYTES];
229    fn sign(&self, message: &[u8]) -> [u8; SIGN_BYTES];
230    fn to_x25519_secret_key(&self) -> [u8; CURVE25519_SECRETKEYBYTES];
231}
232
233impl IdentityKeyAuthority for UserKeypair {
234    fn public_key(&self) -> [u8; SIGN_PUBLICKEYBYTES] {
235        self.public_key()
236    }
237
238    fn sign(&self, message: &[u8]) -> [u8; SIGN_BYTES] {
239        self.sign(message)
240    }
241
242    fn to_x25519_secret_key(&self) -> [u8; CURVE25519_SECRETKEYBYTES] {
243        self.to_x25519_secret_key()
244    }
245}
246
247impl DeviceSigningAuthority for UserKeypair {
248    fn public_key_hex(&self) -> String {
249        public_key_hex(self)
250    }
251
252    fn sign(&self, message: &[u8]) -> [u8; SIGN_BYTES] {
253        self.sign(message)
254    }
255}
256
257impl UserKeypair {
258    /// Generate a new random Ed25519 keypair. The unmanaged primitive behind
259    /// every identity-establishing act — creating, joining, or restoring a
260    /// store; also lets host code (and its tests) mint an identity directly.
261    pub fn generate() -> Self {
262        let mut seed = [0u8; 32];
263        rand::rng().fill_bytes(&mut seed);
264        let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
265        Self { signing_key }
266    }
267
268    /// Reconstruct a keypair from its 64-byte Ed25519 signing key (seed + public),
269    /// deriving the public key from it and validating that the bytes are a real
270    /// keypair. This is the single place stored signing-key bytes become a
271    /// `UserKeypair`, so a torn or corrupt signing key fails at the persistence
272    /// boundary.
273    pub fn from_signing_key_bytes(
274        signing_key: &[u8; SIGN_SECRETKEYBYTES],
275    ) -> Result<Self, KeyError> {
276        let signing_key = ed25519_dalek::SigningKey::from_keypair_bytes(signing_key)
277            .map_err(KeyError::SigningKey)?;
278        Ok(Self { signing_key })
279    }
280
281    pub fn public_key(&self) -> [u8; SIGN_PUBLICKEYBYTES] {
282        self.signing_key.verifying_key().to_bytes()
283    }
284
285    pub fn to_keypair_bytes(&self) -> [u8; SIGN_SECRETKEYBYTES] {
286        self.signing_key.to_keypair_bytes()
287    }
288
289    pub fn derive_signing_key(&self, domain: &[u8], context: &[u8]) -> Self {
290        use sha2::{Digest, Sha256};
291
292        let mut derivation = Sha256::new();
293        derivation.update(domain);
294        derivation.update(self.signing_key.to_bytes());
295        derivation.update(context);
296        let seed: [u8; 32] = derivation.finalize().into();
297        Self {
298            signing_key: SigningKey::from_bytes(&seed),
299        }
300    }
301
302    /// Sign a message, returning a 64-byte detached signature.
303    pub fn sign(&self, message: &[u8]) -> [u8; SIGN_BYTES] {
304        self.signing_key.sign(message).to_bytes()
305    }
306
307    /// Derive the X25519 secret key from this Ed25519 signing key.
308    pub fn to_x25519_secret_key(&self) -> [u8; CURVE25519_SECRETKEYBYTES] {
309        self.signing_key.to_scalar_bytes()
310    }
311
312    /// Derive the X25519 public key from this Ed25519 public key.
313    pub fn to_x25519_public_key(&self) -> [u8; CURVE25519_PUBLICKEYBYTES] {
314        self.signing_key.verifying_key().to_montgomery().to_bytes()
315    }
316}
317
318/// Hex-encode the public key attached to `keypair`.
319pub fn public_key_hex<A: IdentityKeyAuthority + ?Sized>(keypair: &A) -> String {
320    hex::encode(keypair.public_key())
321}
322
323/// Sign `message` and return the hex-encoded public key and detached signature.
324pub fn sign_hex<A: IdentityKeyAuthority + ?Sized>(keypair: &A, message: &[u8]) -> (String, String) {
325    (public_key_hex(keypair), hex::encode(keypair.sign(message)))
326}
327
328/// Verify a detached Ed25519 signature against a public key.
329pub(crate) fn verify_signature(
330    signature: &[u8; SIGN_BYTES],
331    message: &[u8],
332    public_key: &[u8; SIGN_PUBLICKEYBYTES],
333) -> bool {
334    let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(public_key) else {
335        return false;
336    };
337    let sig = ed25519_dalek::Signature::from_bytes(signature);
338    vk.verify(message, &sig).is_ok()
339}
340
341/// Verify a hex-encoded detached Ed25519 signature (`sig_hex`) over `message`
342/// against a hex-encoded public key (`pk_hex`). Malformed hex, a wrong-length key
343/// or signature, or a non-matching signature all fail closed (false). The shared
344/// hex front-end of this crate's raw signature check, used by signed Store
345/// objects and
346/// membership entries so the decode-and-verify path lives in one place.
347pub fn verify_signature_hex(pk_hex: &str, sig_hex: &str, message: &[u8]) -> bool {
348    let Ok(pk_bytes) = hex::decode(pk_hex) else {
349        return false;
350    };
351    let Ok(sig_bytes) = hex::decode(sig_hex) else {
352        return false;
353    };
354    let Ok(pk): Result<[u8; SIGN_PUBLICKEYBYTES], _> = pk_bytes.try_into() else {
355        return false;
356    };
357    let Ok(sig): Result<[u8; SIGN_BYTES], _> = sig_bytes.try_into() else {
358        return false;
359    };
360    verify_signature(&sig, message, &pk)
361}
362
363/// Encrypt a message to a recipient's X25519 public key using a sealed box.
364/// The sender is anonymous -- only the recipient can decrypt.
365pub fn seal_box_encrypt(
366    message: &[u8],
367    recipient_x25519_pk: &[u8; CURVE25519_PUBLICKEYBYTES],
368) -> Vec<u8> {
369    crypto_box::PublicKey::from(*recipient_x25519_pk)
370        .seal(&mut crypto_box::aead::OsRng, message)
371        .expect("sealed box encryption should not fail")
372}
373
374/// Decrypt a sealed box using the recipient's X25519 secret key.
375/// `crypto_box::SecretKey::unseal` derives the recipient public key internally.
376pub fn seal_box_decrypt(
377    ciphertext: &[u8],
378    recipient_x25519_sk: &[u8; CURVE25519_SECRETKEYBYTES],
379) -> Result<Vec<u8>, KeyError> {
380    crypto_box::SecretKey::from(*recipient_x25519_sk)
381        .unseal(ciphertext)
382        .map_err(|_| KeyError::SealedBoxDecryption)
383}
384
385/// Convert an Ed25519 public key to an X25519 public key.
386///
387/// This is used when we only have a remote user's Ed25519 public key (hex string)
388/// and need to encrypt something to them via sealed box. The `UserKeypair` methods
389/// handle the local case; this handles the remote case.
390pub fn ed25519_to_x25519_public_key(
391    ed25519_pk: &[u8; SIGN_PUBLICKEYBYTES],
392) -> Result<[u8; CURVE25519_PUBLICKEYBYTES], KeyError> {
393    let vk = ed25519_dalek::VerifyingKey::from_bytes(ed25519_pk)
394        .map_err(|_| KeyError::InvalidEd25519PublicKey)?;
395    if vk.is_weak() {
396        return Err(KeyError::WeakEd25519PublicKey);
397    }
398    Ok(vk.to_montgomery().to_bytes())
399}
400
401pub fn ed25519_hex_to_x25519_public_key(
402    ed25519_pubkey_hex: &str,
403) -> Result<[u8; CURVE25519_PUBLICKEYBYTES], KeyError> {
404    let public_key = hex::decode(ed25519_pubkey_hex).map_err(|source| KeyError::Hex {
405        subject: "public key",
406        source,
407    })?;
408    let actual = public_key.len();
409    let public_key: [u8; SIGN_PUBLICKEYBYTES] =
410        public_key.try_into().map_err(|_| KeyError::InvalidLength {
411            subject: "public key",
412            expected: SIGN_PUBLICKEYBYTES,
413            actual,
414        })?;
415    ed25519_to_x25519_public_key(&public_key)
416}
417
418/// Derive an X25519 shared secret after rejecting public inputs that cannot
419/// identify a peer. Low-order public keys produce the all-zero shared secret;
420/// that result is never usable as recipient identity material.
421pub fn x25519_shared_secret(
422    local_secret: [u8; CURVE25519_SECRETKEYBYTES],
423    peer_public: [u8; CURVE25519_PUBLICKEYBYTES],
424) -> Result<[u8; CURVE25519_PUBLICKEYBYTES], KeyError> {
425    if peer_public == [0; CURVE25519_PUBLICKEYBYTES] {
426        return Err(KeyError::AllZeroX25519PublicKey);
427    }
428    let shared = x25519_dalek::x25519(local_secret, peer_public);
429    if shared == [0; CURVE25519_PUBLICKEYBYTES] {
430        return Err(KeyError::AllZeroX25519SharedSecret);
431    }
432    Ok(shared)
433}
434
435use crate::encryption::MasterKeyring;
436
437/// A store's master keyring's custody: who unlocks it, where a newly
438/// established or rotated one is written, and how it is removed. Implemented
439/// once per protection policy (the OS keyring, a passphrase-wrapped file, an
440/// in-memory session value, or a host's own).
441pub trait MasterKeyCustody: Send + Sync {
442    /// The store's master keyring for this session. `Ok(None)` means the store
443    /// has never had one established (a fresh store before create/join) —
444    /// distinct from a failure to produce one (wrong passphrase, unreadable
445    /// backing store), which is `Err`.
446    fn unlock(&self) -> Result<Option<MasterKeyring>, KeyError>;
447
448    /// Protect and store `keyring`, replacing whatever is stored. Serves both
449    /// establishment (create/join/restore) and rotation re-protection (member
450    /// removal, the per-cycle refresh adoption). Idempotent.
451    fn persist(&self, keyring: &MasterKeyring) -> Result<(), KeyError>;
452
453    /// Remove the stored keyring. `Ok` when nothing was stored.
454    fn forget(&self) -> Result<(), KeyError>;
455}
456
457/// Why a scoped write could not get the Store key its rows are routed under.
458#[derive(Debug, Error)]
459pub enum RoutingEncryptionError {
460    /// Custody could not produce the keyring — a wrong passphrase, an
461    /// unreadable backing store. Distinct from [`Self::NotEstablished`], which
462    /// is a legitimate absence rather than a failure.
463    #[error("custody error: {0}")]
464    Custody(#[from] KeyError),
465    /// Custody unlocked no keyring. A scoped write routes each row under the
466    /// Store key, so it cannot proceed before one is established.
467    #[error("a scoped write requires an established Store key")]
468    NotEstablished,
469}
470
471/// A device's signing identity's custody FOR ONE STORE: who unlocks it,
472/// where a newly established one is written, and how it is removed. The
473/// signing-key sibling of [`MasterKeyCustody`], same three-method shape and
474/// the same per-store selection, over [`UserKeypair`] instead of a store's
475/// master keyring.
476pub trait DeviceIdentityCustody: Send + Sync {
477    /// This store's established signing identity. `Ok(None)` means none has
478    /// ever been established — distinct from a failure to produce one (wrong
479    /// passphrase, unreadable backing store), which is `Err`.
480    fn unlock(&self) -> Result<Option<UserKeypair>, KeyError>;
481
482    /// Protect and store `keypair`, replacing whatever is stored. Idempotent.
483    fn persist(&self, keypair: &UserKeypair) -> Result<(), KeyError>;
484
485    /// Establish this Store's identity without replacing a different identity.
486    /// Repeating the same identity is idempotent.
487    fn establish(&self, keypair: &UserKeypair) -> Result<(), KeyError> {
488        if let Some(existing) = self.unlock()? {
489            if existing.public_key() != keypair.public_key() {
490                return Err(KeyError::IdentityMismatch {
491                    existing_pubkey_hex: public_key_hex(&existing),
492                    imported_pubkey_hex: public_key_hex(keypair),
493                });
494            }
495        }
496        self.persist(keypair)?;
497        tracing::info!("Established this store's Ed25519 signing identity");
498        Ok(())
499    }
500
501    /// Remove the stored identity. `Ok` when nothing was stored.
502    fn forget(&self) -> Result<(), KeyError>;
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn keypair_generation_produces_valid_keys() {
511        let kp = UserKeypair::generate();
512
513        assert_eq!(kp.to_keypair_bytes().len(), SIGN_SECRETKEYBYTES);
514        assert_eq!(kp.public_key().len(), SIGN_PUBLICKEYBYTES);
515
516        // Keys should not be all zeros (astronomically unlikely)
517        assert!(kp.to_keypair_bytes().iter().any(|&b| b != 0));
518        assert!(kp.public_key().iter().any(|&b| b != 0));
519    }
520
521    #[test]
522    fn two_keypairs_are_distinct() {
523        let kp1 = UserKeypair::generate();
524        let kp2 = UserKeypair::generate();
525        assert_ne!(kp1.public_key(), kp2.public_key());
526    }
527
528    #[test]
529    fn sign_and_verify_roundtrip() {
530        let kp = UserKeypair::generate();
531        let message = b"changeset payload";
532
533        let sig = kp.sign(message);
534        assert!(verify_signature(&sig, message, &kp.public_key()));
535    }
536
537    #[test]
538    fn keypair_bytes_roundtrip_preserves_signing_identity() {
539        let kp = UserKeypair::generate();
540        let keypair_bytes = kp.to_keypair_bytes();
541        let restored =
542            UserKeypair::from_signing_key_bytes(&keypair_bytes).expect("stored keypair bytes");
543        let message = b"persisted identity";
544
545        assert_eq!(restored.to_keypair_bytes(), keypair_bytes);
546        assert_eq!(restored.public_key(), kp.public_key());
547        assert!(verify_signature(
548            &restored.sign(message),
549            message,
550            &restored.public_key()
551        ));
552    }
553
554    #[test]
555    fn sign_hex_returns_public_key_and_valid_signature() {
556        let kp = UserKeypair::generate();
557        let message = b"changeset payload";
558
559        let (pk_hex, sig_hex) = sign_hex(&kp, message);
560
561        assert_eq!(pk_hex, public_key_hex(&kp));
562        assert!(verify_signature_hex(&pk_hex, &sig_hex, message));
563    }
564
565    #[test]
566    fn verify_rejects_wrong_message() {
567        let kp = UserKeypair::generate();
568        let sig = kp.sign(b"original");
569        assert!(!verify_signature(&sig, b"tampered", &kp.public_key()));
570    }
571
572    #[test]
573    fn verify_rejects_wrong_key() {
574        let kp1 = UserKeypair::generate();
575        let kp2 = UserKeypair::generate();
576        let sig = kp1.sign(b"message");
577        assert!(!verify_signature(&sig, b"message", &kp2.public_key()));
578    }
579
580    #[test]
581    fn sign_empty_message() {
582        let kp = UserKeypair::generate();
583        let sig = kp.sign(b"");
584        assert!(verify_signature(&sig, b"", &kp.public_key()));
585    }
586
587    #[test]
588    fn ed25519_to_x25519_conversion() {
589        let kp = UserKeypair::generate();
590        let x_sk = kp.to_x25519_secret_key();
591        let x_pk = kp.to_x25519_public_key();
592        let converted = ed25519_to_x25519_public_key(&kp.public_key()).unwrap();
593
594        // Should produce non-zero 32-byte keys
595        assert_eq!(x_sk.len(), 32);
596        assert_eq!(x_pk.len(), 32);
597        assert!(x_sk.iter().any(|&b| b != 0));
598        assert!(x_pk.iter().any(|&b| b != 0));
599        assert_eq!(converted, x_pk);
600    }
601
602    #[test]
603    fn ed25519_to_x25519_rejects_off_curve_bytes() {
604        let mut bytes = [0u8; SIGN_PUBLICKEYBYTES];
605        bytes[0] = 2;
606
607        let error = ed25519_to_x25519_public_key(&bytes).expect_err("invalid point fails");
608
609        assert!(matches!(error, KeyError::InvalidEd25519PublicKey));
610        assert!(error
611            .to_string()
612            .contains("invalid Ed25519 public key point"));
613    }
614
615    #[test]
616    fn ed25519_to_x25519_rejects_the_identity_point() {
617        let mut identity = [0; SIGN_PUBLICKEYBYTES];
618        identity[0] = 1;
619
620        let error = ed25519_to_x25519_public_key(&identity)
621            .expect_err("a weak recipient point must not produce a shared key");
622
623        assert!(matches!(error, KeyError::WeakEd25519PublicKey));
624    }
625
626    #[test]
627    fn x25519_shared_secret_rejects_the_all_zero_public_key() {
628        let local = UserKeypair::generate();
629
630        let error =
631            x25519_shared_secret(local.to_x25519_secret_key(), [0; CURVE25519_PUBLICKEYBYTES])
632                .expect_err("an all-zero public key must not produce recipient identity material");
633
634        assert!(matches!(error, KeyError::AllZeroX25519PublicKey));
635    }
636
637    #[test]
638    fn x25519_shared_secret_rejects_a_nonzero_low_order_public_key() {
639        let local = UserKeypair::generate();
640        let mut low_order = [0; CURVE25519_PUBLICKEYBYTES];
641        low_order[0] = 1;
642
643        let error = x25519_shared_secret(local.to_x25519_secret_key(), low_order)
644            .expect_err("a low-order public key must not produce recipient identity material");
645
646        assert!(matches!(error, KeyError::AllZeroX25519SharedSecret));
647    }
648
649    #[test]
650    fn ed25519_to_x25519_is_deterministic() {
651        let kp = UserKeypair::generate();
652        let x_sk1 = kp.to_x25519_secret_key();
653        let x_sk2 = kp.to_x25519_secret_key();
654        assert_eq!(x_sk1, x_sk2);
655    }
656
657    #[test]
658    fn sealed_box_roundtrip() {
659        let kp = UserKeypair::generate();
660        let x_pk = kp.to_x25519_public_key();
661        let x_sk = kp.to_x25519_secret_key();
662
663        let plaintext = b"store encryption key material";
664        let ciphertext = seal_box_encrypt(plaintext, &x_pk);
665
666        assert_eq!(ciphertext.len(), plaintext.len() + SEALBYTES);
667
668        let decrypted = seal_box_decrypt(&ciphertext, &x_sk).unwrap();
669        assert_eq!(decrypted, plaintext);
670    }
671
672    #[test]
673    fn sealed_box_wrong_key_fails() {
674        let kp1 = UserKeypair::generate();
675        let kp2 = UserKeypair::generate();
676
677        let ciphertext = seal_box_encrypt(b"secret", &kp1.to_x25519_public_key());
678
679        let result = seal_box_decrypt(&ciphertext, &kp2.to_x25519_secret_key());
680        assert!(result.is_err());
681    }
682
683    #[test]
684    fn sealed_box_empty_message() {
685        let kp = UserKeypair::generate();
686        let x_pk = kp.to_x25519_public_key();
687        let x_sk = kp.to_x25519_secret_key();
688
689        let ciphertext = seal_box_encrypt(b"", &x_pk);
690        let decrypted = seal_box_decrypt(&ciphertext, &x_sk).unwrap();
691        assert!(decrypted.is_empty());
692    }
693
694    #[test]
695    fn sealed_box_too_short_ciphertext() {
696        let kp = UserKeypair::generate();
697        let result = seal_box_decrypt(&[0u8; 10], &kp.to_x25519_secret_key());
698        assert!(result.is_err());
699    }
700
701    /// Pins the actionable content of `MissingKeychainEntitlement`'s message:
702    /// the real OS error and the real fix (a team-prefixed
703    /// `keychain-access-groups` entitlement backed by a provisioning
704    /// profile), not the wrong "must be signed" advice this replaced.
705    #[test]
706    fn missing_keychain_entitlement_message_names_the_real_error_and_fix() {
707        let message = KeyError::MissingKeychainEntitlement.to_string();
708
709        assert!(message.contains("-34018"), "{message}");
710        assert!(message.contains("errSecMissingEntitlement"), "{message}");
711        assert!(message.contains("keychain-access-groups"), "{message}");
712        assert!(message.contains("provisioning profile"), "{message}");
713        assert!(message.contains("DEVELOPMENT_TEAM"), "{message}");
714        assert!(
715            !message.contains("must be signed"),
716            "a bare 'signed binary' is the wrong fix and must not be implied: {message}"
717        );
718    }
719
720    #[test]
721    fn credentials_debug_redacts_s3_secret_and_oauth_token() {
722        let s3 = CloudHomeCredentials::S3 {
723            access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
724            secret_key: "s3-secret-value-do-not-print".to_string(),
725        };
726        let debug = format!("{s3:?}");
727        assert!(debug.contains("<redacted>"), "{debug}");
728        assert!(debug.contains("AKIAIOSFODNN7EXAMPLE"), "{debug}");
729        assert!(
730            !debug.contains("s3-secret-value-do-not-print"),
731            "S3 secret key leaked: {debug}"
732        );
733
734        let oauth = CloudHomeCredentials::OAuth {
735            tokens: crate::keys::OAuthTokens {
736                access_token: "oauth-token-do-not-print".to_string(),
737                refresh_token: None,
738                expires_at: None,
739            },
740        };
741        let debug = format!("{oauth:?}");
742        assert!(debug.contains("<redacted>"), "{debug}");
743        assert!(
744            !debug.contains("oauth-token-do-not-print"),
745            "OAuth token leaked: {debug}"
746        );
747    }
748}