Skip to main content

joy_core/auth/
sign.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Ed25519 identity keypair for signing and verification.
5//!
6//! The keypair is derived deterministically from a `DerivedKey` (Argon2id output).
7//! The private key exists only transiently in memory and is zeroed on drop.
8
9use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
10
11use super::derive::DerivedKey;
12use crate::error::JoyError;
13
14/// Ed25519 signing keypair. Private key is zeroed on drop.
15pub struct IdentityKeypair {
16    signing_key: SigningKey,
17}
18
19/// Ed25519 public key. Stored in project.yaml per member as hex.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct PublicKey(VerifyingKey);
22
23impl IdentityKeypair {
24    /// Create a keypair from derived key material (32-byte Ed25519 seed).
25    pub fn from_derived_key(key: &DerivedKey) -> Self {
26        let signing_key = SigningKey::from_bytes(key.as_bytes());
27        Self { signing_key }
28    }
29
30    /// Create a keypair from a raw Ed25519 signing key.
31    pub fn from_signing_key(key: SigningKey) -> Self {
32        Self { signing_key: key }
33    }
34
35    /// Create a keypair from a 32-byte seed (e.g. for token-derived sessions).
36    pub fn from_seed(seed: &[u8; 32]) -> Self {
37        let signing_key = SigningKey::from_bytes(seed);
38        Self { signing_key }
39    }
40
41    /// Generate a random keypair (for one-time token keys).
42    pub fn from_random() -> Self {
43        use rand::rngs::OsRng;
44        let signing_key = SigningKey::generate(&mut OsRng);
45        Self { signing_key }
46    }
47
48    /// Derive a deterministic keypair from arbitrary data (e.g. token + project ID).
49    /// Uses SHA-256 to produce a 32-byte seed.
50    pub fn from_token_seed(token: &str, project_id: &str) -> Self {
51        use sha2::{Digest, Sha256};
52        let mut hasher = Sha256::new();
53        hasher.update(token.as_bytes());
54        hasher.update(project_id.as_bytes());
55        let hash = hasher.finalize();
56        let mut seed = [0u8; 32];
57        seed.copy_from_slice(&hash);
58        Self::from_seed(&seed)
59    }
60
61    /// Get the public key for this keypair.
62    pub fn public_key(&self) -> PublicKey {
63        PublicKey(self.signing_key.verifying_key())
64    }
65
66    /// Sign a message with this keypair.
67    pub fn sign(&self, message: &[u8]) -> Vec<u8> {
68        let sig: Signature = self.signing_key.sign(message);
69        sig.to_bytes().to_vec()
70    }
71
72    /// Extract the 32-byte seed for at-rest persistence (e.g. delegation key files).
73    /// The returned bytes are the private key material; the caller is responsible
74    /// for protecting them (file permissions 0600, etc.).
75    pub fn to_seed_bytes(&self) -> [u8; 32] {
76        self.signing_key.to_bytes()
77    }
78}
79
80impl PublicKey {
81    /// Verify a signature against this public key.
82    pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), JoyError> {
83        let sig = Signature::from_slice(signature)
84            .map_err(|e| JoyError::AuthFailed(format!("invalid signature: {e}")))?;
85        self.0
86            .verify(message, &sig)
87            .map_err(|_| JoyError::AuthFailed("signature verification failed".into()))
88    }
89
90    /// Encode as hex string for storage in project.yaml.
91    pub fn to_hex(&self) -> String {
92        hex::encode(self.0.as_bytes())
93    }
94
95    /// Decode from hex string.
96    pub fn from_hex(s: &str) -> Result<Self, JoyError> {
97        let bytes =
98            hex::decode(s).map_err(|e| JoyError::AuthFailed(format!("invalid public key: {e}")))?;
99        let arr: [u8; 32] = bytes
100            .try_into()
101            .map_err(|_| JoyError::AuthFailed("public key must be 32 bytes".into()))?;
102        let key = VerifyingKey::from_bytes(&arr)
103            .map_err(|e| JoyError::AuthFailed(format!("invalid Ed25519 key: {e}")))?;
104        Ok(Self(key))
105    }
106}
107
108impl Drop for IdentityKeypair {
109    fn drop(&mut self) {
110        // SigningKey implements Zeroize via ed25519-dalek
111        // The drop is handled automatically, but we implement Drop
112        // to document the security intent.
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::auth::derive;
120
121    const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
122
123    fn test_keypair() -> (IdentityKeypair, DerivedKey) {
124        let salt = derive::Salt::from_hex(
125            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
126        )
127        .unwrap();
128        let key = derive::derive_key(TEST_PASSPHRASE, &salt).unwrap();
129        let keypair = IdentityKeypair::from_derived_key(&key);
130        (keypair, key)
131    }
132
133    #[test]
134    fn keypair_deterministic() {
135        let salt = derive::Salt::from_hex(
136            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
137        )
138        .unwrap();
139        let k1 = derive::derive_key(TEST_PASSPHRASE, &salt).unwrap();
140        let k2 = derive::derive_key(TEST_PASSPHRASE, &salt).unwrap();
141        let kp1 = IdentityKeypair::from_derived_key(&k1);
142        let kp2 = IdentityKeypair::from_derived_key(&k2);
143        assert_eq!(kp1.public_key(), kp2.public_key());
144    }
145
146    #[test]
147    fn sign_verify_roundtrip() {
148        let (keypair, _) = test_keypair();
149        let message = b"hello world";
150        let signature = keypair.sign(message);
151        assert!(keypair.public_key().verify(message, &signature).is_ok());
152    }
153
154    #[test]
155    fn verify_wrong_message() {
156        let (keypair, _) = test_keypair();
157        let signature = keypair.sign(b"original");
158        assert!(keypair
159            .public_key()
160            .verify(b"tampered", &signature)
161            .is_err());
162    }
163
164    #[test]
165    fn verify_wrong_key() {
166        let (keypair, _) = test_keypair();
167        let signature = keypair.sign(b"hello");
168
169        // Different passphrase = different key
170        let salt = derive::generate_salt();
171        let other_key =
172            derive::derive_key("alpha bravo charlie delta echo foxtrot", &salt).unwrap();
173        let other_kp = IdentityKeypair::from_derived_key(&other_key);
174        assert!(other_kp.public_key().verify(b"hello", &signature).is_err());
175    }
176
177    #[test]
178    fn public_key_hex_roundtrip() {
179        let (keypair, _) = test_keypair();
180        let pk = keypair.public_key();
181        let hex = pk.to_hex();
182        let parsed = PublicKey::from_hex(&hex).unwrap();
183        assert_eq!(pk, parsed);
184    }
185}