Skip to main content

joy_crypt/
identity.rs

1//! Ed25519 identity primitives.
2//!
3//! `Keypair` derives deterministically from a 32-byte seed (or a
4//! `DerivedKey` from `kdf`), produces signatures, and exposes its raw
5//! seed bytes for at-rest persistence by callers that own their own
6//! storage policy. `PublicKey` is the verification half, hex-encoded
7//! when stored alongside other project metadata.
8
9use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
10use sha2::{Digest, Sha512};
11
12use crate::kdf::DerivedKey;
13use crate::Error;
14
15/// Ed25519 signing keypair. Private key is zeroed on drop (handled by
16/// `ed25519-dalek`'s internal `Zeroize`).
17pub struct Keypair {
18    signing_key: SigningKey,
19}
20
21/// Ed25519 verification key. Stored in project.yaml as hex.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct PublicKey(VerifyingKey);
24
25impl Keypair {
26    /// Create a keypair from a 32-byte Ed25519 seed.
27    pub fn from_seed(seed: &[u8; 32]) -> Self {
28        Self {
29            signing_key: SigningKey::from_bytes(seed),
30        }
31    }
32
33    /// Create a keypair from derived key material (Argon2id output).
34    pub fn from_derived_key(key: &DerivedKey) -> Self {
35        Self::from_seed(key.as_bytes())
36    }
37
38    /// Generate a random keypair (for ephemeral session or one-time keys).
39    pub fn from_random() -> Self {
40        use rand::rngs::OsRng;
41        Self {
42            signing_key: SigningKey::generate(&mut OsRng),
43        }
44    }
45
46    /// Wrap an existing `SigningKey` (for callers that hold one already).
47    pub fn from_signing_key(key: SigningKey) -> Self {
48        Self { signing_key: key }
49    }
50
51    /// Get the public key for this keypair.
52    pub fn public_key(&self) -> PublicKey {
53        PublicKey(self.signing_key.verifying_key())
54    }
55
56    /// Sign a message and return the 64-byte signature.
57    pub fn sign(&self, message: &[u8]) -> Vec<u8> {
58        let sig: Signature = self.signing_key.sign(message);
59        sig.to_bytes().to_vec()
60    }
61
62    /// Extract the 32-byte seed for at-rest persistence. The caller is
63    /// responsible for protecting the bytes (file permissions, encryption).
64    pub fn to_seed_bytes(&self) -> [u8; 32] {
65        self.signing_key.to_bytes()
66    }
67
68    /// Convert this Ed25519 signing key into an X25519 secret scalar
69    /// (32 bytes, bit-clamped). Used by the pairwise wrap path so the
70    /// same identity that signs Auth events also drives Crypt ECDH;
71    /// no separate keypair is stored. The conversion follows the
72    /// standard Ed25519-to-X25519 procedure: SHA-512 of the seed,
73    /// take the first 32 bytes, apply X25519 bit clamping.
74    pub fn to_x25519_secret_bytes(&self) -> [u8; 32] {
75        let seed = self.signing_key.to_bytes();
76        let hash = Sha512::digest(seed);
77        let mut secret = [0u8; 32];
78        secret.copy_from_slice(&hash[..32]);
79        secret[0] &= 248;
80        secret[31] &= 127;
81        secret[31] |= 64;
82        secret
83    }
84}
85
86impl PublicKey {
87    /// Verify a signature against this public key.
88    pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), Error> {
89        let sig = Signature::from_slice(signature).map_err(|_| Error::SignatureVerification)?;
90        self.0
91            .verify(message, &sig)
92            .map_err(|_| Error::SignatureVerification)
93    }
94
95    /// Encode as hex string for storage.
96    pub fn to_hex(&self) -> String {
97        hex::encode(self.0.as_bytes())
98    }
99
100    /// Decode from hex string.
101    pub fn from_hex(s: &str) -> Result<Self, Error> {
102        let bytes = hex::decode(s).map_err(|e| Error::InvalidHex(e.to_string()))?;
103        let arr: [u8; 32] = bytes
104            .try_into()
105            .map_err(|v: Vec<u8>| Error::InvalidLength {
106                expected: 32,
107                got: v.len(),
108            })?;
109        let key = VerifyingKey::from_bytes(&arr).map_err(|_| Error::InvalidPublicKey)?;
110        Ok(Self(key))
111    }
112
113    /// Raw 32-byte form. Used when binding the public key into
114    /// authenticated-data fields outside hex encoding.
115    pub fn as_bytes(&self) -> [u8; 32] {
116        self.0.to_bytes()
117    }
118
119    /// Convert this Ed25519 verification key to its X25519 (Montgomery
120    /// form) public counterpart. Pairs with
121    /// `Keypair::to_x25519_secret_bytes` for ECDH on the same identity
122    /// material. Returns 32 bytes suitable for `x25519_dalek::PublicKey`.
123    pub fn to_x25519_public_bytes(&self) -> [u8; 32] {
124        self.0.to_montgomery().to_bytes()
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::kdf::{derive_argon2id, Salt};
132
133    const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
134
135    fn fixed_seed() -> [u8; 32] {
136        [7u8; 32]
137    }
138
139    fn derived_keypair() -> Keypair {
140        let salt =
141            Salt::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
142                .unwrap();
143        let key = derive_argon2id(TEST_PASSPHRASE, &salt).unwrap();
144        Keypair::from_derived_key(&key)
145    }
146
147    #[test]
148    fn from_seed_deterministic() {
149        let seed = fixed_seed();
150        let a = Keypair::from_seed(&seed);
151        let b = Keypair::from_seed(&seed);
152        assert_eq!(a.public_key(), b.public_key());
153    }
154
155    #[test]
156    fn from_derived_key_deterministic() {
157        let kp1 = derived_keypair();
158        let kp2 = derived_keypair();
159        assert_eq!(kp1.public_key(), kp2.public_key());
160    }
161
162    #[test]
163    fn random_keypairs_differ() {
164        let a = Keypair::from_random();
165        let b = Keypair::from_random();
166        assert_ne!(a.public_key(), b.public_key());
167    }
168
169    #[test]
170    fn sign_verify_roundtrip() {
171        let kp = Keypair::from_seed(&fixed_seed());
172        let sig = kp.sign(b"hello");
173        kp.public_key().verify(b"hello", &sig).unwrap();
174    }
175
176    #[test]
177    fn verify_rejects_tampered_message() {
178        let kp = Keypair::from_seed(&fixed_seed());
179        let sig = kp.sign(b"original");
180        assert!(kp.public_key().verify(b"tampered", &sig).is_err());
181    }
182
183    #[test]
184    fn verify_rejects_other_key() {
185        let kp_a = Keypair::from_seed(&[1u8; 32]);
186        let kp_b = Keypair::from_seed(&[2u8; 32]);
187        let sig = kp_a.sign(b"hello");
188        assert!(kp_b.public_key().verify(b"hello", &sig).is_err());
189    }
190
191    #[test]
192    fn public_key_hex_roundtrip() {
193        let kp = Keypair::from_seed(&fixed_seed());
194        let pk = kp.public_key();
195        let parsed = PublicKey::from_hex(&pk.to_hex()).unwrap();
196        assert_eq!(pk, parsed);
197    }
198
199    #[test]
200    fn public_key_invalid_hex_rejected() {
201        assert!(matches!(
202            PublicKey::from_hex("zzzz").unwrap_err(),
203            Error::InvalidHex(_)
204        ));
205    }
206
207    #[test]
208    fn public_key_invalid_length_rejected() {
209        assert!(matches!(
210            PublicKey::from_hex("00").unwrap_err(),
211            Error::InvalidLength { expected: 32, .. }
212        ));
213    }
214
215    #[test]
216    fn seed_roundtrip_preserves_keypair() {
217        let seed = fixed_seed();
218        let kp = Keypair::from_seed(&seed);
219        let extracted = kp.to_seed_bytes();
220        let kp2 = Keypair::from_seed(&extracted);
221        assert_eq!(kp.public_key(), kp2.public_key());
222    }
223}