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 used for Store identities and derived device signers.
209/// The same seed can derive an X25519 keypair for key wrapping.
210///
211/// A Store identity authorizes membership and device registration. Each
212/// registration derives a device signing key from that identity and its
213/// Store root and registration origin; the identity and device signer are
214/// distinct protocol roles.
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/// Custody of the Store identity used by this installation: who unlocks it,
472/// where an established identity is written, and how it is removed. Selected
473/// per Store, like [`MasterKeyCustody`], but retains a [`UserKeypair`] instead
474/// of the Store's encryption keyring.
475pub trait DeviceIdentityCustody: Send + Sync {
476    /// This store's established signing identity. `Ok(None)` means none has
477    /// ever been established — distinct from a failure to produce one (wrong
478    /// passphrase, unreadable backing store), which is `Err`.
479    fn unlock(&self) -> Result<Option<UserKeypair>, KeyError>;
480
481    /// Protect and store `keypair`, replacing whatever is stored. Idempotent.
482    fn persist(&self, keypair: &UserKeypair) -> Result<(), KeyError>;
483
484    /// Establish this Store's identity without replacing a different identity.
485    /// Repeating the same identity is idempotent.
486    fn establish(&self, keypair: &UserKeypair) -> Result<(), KeyError> {
487        if let Some(existing) = self.unlock()? {
488            if existing.public_key() != keypair.public_key() {
489                return Err(KeyError::IdentityMismatch {
490                    existing_pubkey_hex: public_key_hex(&existing),
491                    imported_pubkey_hex: public_key_hex(keypair),
492                });
493            }
494        }
495        self.persist(keypair)?;
496        tracing::info!("Established this store's Ed25519 signing identity");
497        Ok(())
498    }
499
500    /// Remove the stored identity. `Ok` when nothing was stored.
501    fn forget(&self) -> Result<(), KeyError>;
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn keypair_generation_produces_valid_keys() {
510        let kp = UserKeypair::generate();
511
512        assert_eq!(kp.to_keypair_bytes().len(), SIGN_SECRETKEYBYTES);
513        assert_eq!(kp.public_key().len(), SIGN_PUBLICKEYBYTES);
514
515        // Keys should not be all zeros (astronomically unlikely)
516        assert!(kp.to_keypair_bytes().iter().any(|&b| b != 0));
517        assert!(kp.public_key().iter().any(|&b| b != 0));
518    }
519
520    #[test]
521    fn two_keypairs_are_distinct() {
522        let kp1 = UserKeypair::generate();
523        let kp2 = UserKeypair::generate();
524        assert_ne!(kp1.public_key(), kp2.public_key());
525    }
526
527    #[test]
528    fn sign_and_verify_roundtrip() {
529        let kp = UserKeypair::generate();
530        let message = b"changeset payload";
531
532        let sig = kp.sign(message);
533        assert!(verify_signature(&sig, message, &kp.public_key()));
534    }
535
536    #[test]
537    fn keypair_bytes_roundtrip_preserves_signing_identity() {
538        let kp = UserKeypair::generate();
539        let keypair_bytes = kp.to_keypair_bytes();
540        let restored =
541            UserKeypair::from_signing_key_bytes(&keypair_bytes).expect("stored keypair bytes");
542        let message = b"persisted identity";
543
544        assert_eq!(restored.to_keypair_bytes(), keypair_bytes);
545        assert_eq!(restored.public_key(), kp.public_key());
546        assert!(verify_signature(
547            &restored.sign(message),
548            message,
549            &restored.public_key()
550        ));
551    }
552
553    #[test]
554    fn sign_hex_returns_public_key_and_valid_signature() {
555        let kp = UserKeypair::generate();
556        let message = b"changeset payload";
557
558        let (pk_hex, sig_hex) = sign_hex(&kp, message);
559
560        assert_eq!(pk_hex, public_key_hex(&kp));
561        assert!(verify_signature_hex(&pk_hex, &sig_hex, message));
562    }
563
564    #[test]
565    fn verify_rejects_wrong_message() {
566        let kp = UserKeypair::generate();
567        let sig = kp.sign(b"original");
568        assert!(!verify_signature(&sig, b"tampered", &kp.public_key()));
569    }
570
571    #[test]
572    fn verify_rejects_wrong_key() {
573        let kp1 = UserKeypair::generate();
574        let kp2 = UserKeypair::generate();
575        let sig = kp1.sign(b"message");
576        assert!(!verify_signature(&sig, b"message", &kp2.public_key()));
577    }
578
579    #[test]
580    fn sign_empty_message() {
581        let kp = UserKeypair::generate();
582        let sig = kp.sign(b"");
583        assert!(verify_signature(&sig, b"", &kp.public_key()));
584    }
585
586    #[test]
587    fn ed25519_to_x25519_conversion() {
588        let kp = UserKeypair::generate();
589        let x_sk = kp.to_x25519_secret_key();
590        let x_pk = kp.to_x25519_public_key();
591        let converted = ed25519_to_x25519_public_key(&kp.public_key()).unwrap();
592
593        // Should produce non-zero 32-byte keys
594        assert_eq!(x_sk.len(), 32);
595        assert_eq!(x_pk.len(), 32);
596        assert!(x_sk.iter().any(|&b| b != 0));
597        assert!(x_pk.iter().any(|&b| b != 0));
598        assert_eq!(converted, x_pk);
599    }
600
601    #[test]
602    fn ed25519_to_x25519_rejects_off_curve_bytes() {
603        let mut bytes = [0u8; SIGN_PUBLICKEYBYTES];
604        bytes[0] = 2;
605
606        let error = ed25519_to_x25519_public_key(&bytes).expect_err("invalid point fails");
607
608        assert!(matches!(error, KeyError::InvalidEd25519PublicKey));
609        assert!(error
610            .to_string()
611            .contains("invalid Ed25519 public key point"));
612    }
613
614    #[test]
615    fn ed25519_to_x25519_rejects_the_identity_point() {
616        let mut identity = [0; SIGN_PUBLICKEYBYTES];
617        identity[0] = 1;
618
619        let error = ed25519_to_x25519_public_key(&identity)
620            .expect_err("a weak recipient point must not produce a shared key");
621
622        assert!(matches!(error, KeyError::WeakEd25519PublicKey));
623    }
624
625    #[test]
626    fn x25519_shared_secret_rejects_the_all_zero_public_key() {
627        let local = UserKeypair::generate();
628
629        let error =
630            x25519_shared_secret(local.to_x25519_secret_key(), [0; CURVE25519_PUBLICKEYBYTES])
631                .expect_err("an all-zero public key must not produce recipient identity material");
632
633        assert!(matches!(error, KeyError::AllZeroX25519PublicKey));
634    }
635
636    #[test]
637    fn x25519_shared_secret_rejects_a_nonzero_low_order_public_key() {
638        let local = UserKeypair::generate();
639        let mut low_order = [0; CURVE25519_PUBLICKEYBYTES];
640        low_order[0] = 1;
641
642        let error = x25519_shared_secret(local.to_x25519_secret_key(), low_order)
643            .expect_err("a low-order public key must not produce recipient identity material");
644
645        assert!(matches!(error, KeyError::AllZeroX25519SharedSecret));
646    }
647
648    #[test]
649    fn ed25519_to_x25519_is_deterministic() {
650        let kp = UserKeypair::generate();
651        let x_sk1 = kp.to_x25519_secret_key();
652        let x_sk2 = kp.to_x25519_secret_key();
653        assert_eq!(x_sk1, x_sk2);
654    }
655
656    #[test]
657    fn sealed_box_roundtrip() {
658        let kp = UserKeypair::generate();
659        let x_pk = kp.to_x25519_public_key();
660        let x_sk = kp.to_x25519_secret_key();
661
662        let plaintext = b"store encryption key material";
663        let ciphertext = seal_box_encrypt(plaintext, &x_pk);
664
665        assert_eq!(ciphertext.len(), plaintext.len() + SEALBYTES);
666
667        let decrypted = seal_box_decrypt(&ciphertext, &x_sk).unwrap();
668        assert_eq!(decrypted, plaintext);
669    }
670
671    #[test]
672    fn sealed_box_wrong_key_fails() {
673        let kp1 = UserKeypair::generate();
674        let kp2 = UserKeypair::generate();
675
676        let ciphertext = seal_box_encrypt(b"secret", &kp1.to_x25519_public_key());
677
678        let result = seal_box_decrypt(&ciphertext, &kp2.to_x25519_secret_key());
679        assert!(result.is_err());
680    }
681
682    #[test]
683    fn sealed_box_empty_message() {
684        let kp = UserKeypair::generate();
685        let x_pk = kp.to_x25519_public_key();
686        let x_sk = kp.to_x25519_secret_key();
687
688        let ciphertext = seal_box_encrypt(b"", &x_pk);
689        let decrypted = seal_box_decrypt(&ciphertext, &x_sk).unwrap();
690        assert!(decrypted.is_empty());
691    }
692
693    #[test]
694    fn sealed_box_too_short_ciphertext() {
695        let kp = UserKeypair::generate();
696        let result = seal_box_decrypt(&[0u8; 10], &kp.to_x25519_secret_key());
697        assert!(result.is_err());
698    }
699
700    /// Pins the actionable content of `MissingKeychainEntitlement`'s message:
701    /// the real OS error and the real fix (a team-prefixed
702    /// `keychain-access-groups` entitlement backed by a provisioning
703    /// profile), not the wrong "must be signed" advice this replaced.
704    #[test]
705    fn missing_keychain_entitlement_message_names_the_real_error_and_fix() {
706        let message = KeyError::MissingKeychainEntitlement.to_string();
707
708        assert!(message.contains("-34018"), "{message}");
709        assert!(message.contains("errSecMissingEntitlement"), "{message}");
710        assert!(message.contains("keychain-access-groups"), "{message}");
711        assert!(message.contains("provisioning profile"), "{message}");
712        assert!(message.contains("DEVELOPMENT_TEAM"), "{message}");
713        assert!(
714            !message.contains("must be signed"),
715            "a bare 'signed binary' is the wrong fix and must not be implied: {message}"
716        );
717    }
718
719    #[test]
720    fn credentials_debug_redacts_s3_secret_and_oauth_token() {
721        let s3 = CloudHomeCredentials::S3 {
722            access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
723            secret_key: "s3-secret-value-do-not-print".to_string(),
724        };
725        let debug = format!("{s3:?}");
726        assert!(debug.contains("<redacted>"), "{debug}");
727        assert!(debug.contains("AKIAIOSFODNN7EXAMPLE"), "{debug}");
728        assert!(
729            !debug.contains("s3-secret-value-do-not-print"),
730            "S3 secret key leaked: {debug}"
731        );
732
733        let oauth = CloudHomeCredentials::OAuth {
734            tokens: crate::keys::OAuthTokens {
735                access_token: "oauth-token-do-not-print".to_string(),
736                refresh_token: None,
737                expires_at: None,
738            },
739        };
740        let debug = format!("{oauth:?}");
741        assert!(debug.contains("<redacted>"), "{debug}");
742        assert!(
743            !debug.contains("oauth-token-do-not-print"),
744            "OAuth token leaked: {debug}"
745        );
746    }
747}