Skip to main content

joy_core/auth/
seed.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Wrapped-seed identity helpers (ADR-039).
5//!
6//! Each member holds a randomly generated 32-byte seed. The Ed25519
7//! identity keypair derives from this seed. The seed is stored in
8//! `project.yaml` as two AES-256-GCM ciphertexts:
9//!
10//! - `seed_wrap_passphrase`: encrypted under a KEK derived from
11//!   `passphrase + kdf_nonce` via Argon2id.
12//! - `seed_wrap_recovery`: encrypted under a KEK derived from a
13//!   recovery key via Argon2id (same `kdf_nonce`).
14//!
15//! Either wrap unlocks the same seed; the keypair stays stable across
16//! passphrase rotation. The recovery key itself is displayed once at
17//! `joy auth init` and stored externally by the user.
18
19use joy_crypt::kdf::{derive_argon2id, DerivedKey, Salt};
20use joy_crypt::wrap;
21use rand::RngCore;
22use zeroize::Zeroizing;
23
24use crate::error::JoyError;
25
26/// 32-byte recovery key. Displayed once at `joy auth init`. Stored
27/// externally by the user; never persisted by Joy.
28pub struct RecoveryKey(Zeroizing<[u8; 32]>);
29
30impl std::fmt::Debug for RecoveryKey {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.write_str("RecoveryKey(***)")
33    }
34}
35
36impl RecoveryKey {
37    /// Generate a fresh random recovery key.
38    pub fn generate() -> Self {
39        let mut bytes = Zeroizing::new([0u8; 32]);
40        rand::thread_rng().fill_bytes(bytes.as_mut());
41        Self(bytes)
42    }
43
44    /// Encode for one-time display (hex with `joy_r_` prefix).
45    pub fn to_display_string(&self) -> String {
46        format!("joy_r_{}", hex::encode(self.0.as_ref()))
47    }
48
49    /// Parse a user-supplied recovery key. Accepts the `joy_r_` prefix
50    /// or bare hex.
51    pub fn from_user_input(s: &str) -> Result<Self, JoyError> {
52        let trimmed = s.trim().trim_start_matches("joy_r_");
53        let bytes = hex::decode(trimmed)
54            .map_err(|e| JoyError::AuthFailed(format!("invalid recovery key: {e}")))?;
55        let arr: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
56            JoyError::AuthFailed(format!(
57                "recovery key must be 32 bytes ({} hex chars), got {} bytes",
58                64,
59                v.len()
60            ))
61        })?;
62        Ok(Self(Zeroizing::new(arr)))
63    }
64
65    /// Bytes form for KDF input.
66    pub fn as_bytes(&self) -> &[u8; 32] {
67        &self.0
68    }
69}
70
71/// 32-byte identity seed. The Ed25519 keypair derives from this value.
72pub struct Seed(Zeroizing<[u8; 32]>);
73
74impl std::fmt::Debug for Seed {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str("Seed(***)")
77    }
78}
79
80impl Seed {
81    /// Generate a fresh random seed.
82    pub fn generate() -> Self {
83        let mut bytes = Zeroizing::new([0u8; 32]);
84        rand::thread_rng().fill_bytes(bytes.as_mut());
85        Self(bytes)
86    }
87
88    /// Wrap a known 32-byte seed (used by lazy migration where the seed
89    /// is the legacy Argon2id-derived key).
90    pub fn from_bytes(bytes: [u8; 32]) -> Self {
91        Self(Zeroizing::new(bytes))
92    }
93
94    /// Construct a seed from an Argon2id `DerivedKey` (used by lazy
95    /// migration: legacy KEK_passphrase becomes the seed).
96    pub fn from_derived_key(key: &DerivedKey) -> Self {
97        Self::from_bytes(*key.as_bytes())
98    }
99
100    pub fn as_bytes(&self) -> &[u8; 32] {
101        &self.0
102    }
103}
104
105/// Derive the passphrase KEK and wrap a seed under it. Returns the
106/// hex-encoded `nonce || ciphertext || tag`.
107pub fn wrap_seed_with_passphrase(
108    seed: &Seed,
109    passphrase: &str,
110    kdf_nonce: &Salt,
111) -> Result<String, JoyError> {
112    let kek = derive_argon2id(passphrase, kdf_nonce)?;
113    let wrapped = wrap::wrap(kek.as_bytes(), seed.as_bytes());
114    Ok(hex::encode(wrapped))
115}
116
117/// Derive the recovery KEK and wrap a seed under it. Returns hex.
118pub fn wrap_seed_with_recovery(
119    seed: &Seed,
120    recovery_key: &RecoveryKey,
121    kdf_nonce: &Salt,
122) -> Result<String, JoyError> {
123    let pass = hex::encode(recovery_key.as_bytes());
124    let kek = derive_argon2id(&pass, kdf_nonce)?;
125    let wrapped = wrap::wrap(kek.as_bytes(), seed.as_bytes());
126    Ok(hex::encode(wrapped))
127}
128
129/// Unwrap a seed via the passphrase KEK. Returns the 32-byte seed on
130/// success; AES-GCM auth failure indicates wrong passphrase.
131pub fn unwrap_seed_with_passphrase(
132    wrap_hex: &str,
133    passphrase: &str,
134    kdf_nonce: &Salt,
135) -> Result<Seed, JoyError> {
136    let wrapped = hex::decode(wrap_hex)
137        .map_err(|e| JoyError::AuthFailed(format!("invalid seed_wrap_passphrase: {e}")))?;
138    let kek = derive_argon2id(passphrase, kdf_nonce)?;
139    let plain = wrap::unwrap(kek.as_bytes(), &wrapped)
140        .map_err(|_| JoyError::AuthFailed("incorrect passphrase".into()))?;
141    let arr: [u8; 32] = plain.try_into().map_err(|v: Vec<u8>| {
142        JoyError::AuthFailed(format!("seed has wrong length: {}", v.len()))
143    })?;
144    Ok(Seed::from_bytes(arr))
145}
146
147/// Lazy-migration wrap (ADR-039 ยง"Migration is non-disruptive"): when
148/// the legacy Argon2id-derived KEK already equals the seed (because the
149/// pre-wrapped-seed model used the KEK directly as the Ed25519 seed),
150/// wrap the seed under itself. Subsequent passphrase changes decouple
151/// the KEK from the seed naturally.
152pub fn wrap_seed_for_migration(seed: &Seed) -> String {
153    let wrapped = wrap::wrap(seed.as_bytes(), seed.as_bytes());
154    hex::encode(wrapped)
155}
156
157/// Unwrap a seed via the recovery KEK. AES-GCM auth failure indicates
158/// wrong recovery key.
159pub fn unwrap_seed_with_recovery(
160    wrap_hex: &str,
161    recovery_key: &RecoveryKey,
162    kdf_nonce: &Salt,
163) -> Result<Seed, JoyError> {
164    let wrapped = hex::decode(wrap_hex)
165        .map_err(|e| JoyError::AuthFailed(format!("invalid seed_wrap_recovery: {e}")))?;
166    let pass = hex::encode(recovery_key.as_bytes());
167    let kek = derive_argon2id(&pass, kdf_nonce)?;
168    let plain = wrap::unwrap(kek.as_bytes(), &wrapped)
169        .map_err(|_| JoyError::AuthFailed("incorrect recovery key".into()))?;
170    let arr: [u8; 32] = plain.try_into().map_err(|v: Vec<u8>| {
171        JoyError::AuthFailed(format!("seed has wrong length: {}", v.len()))
172    })?;
173    Ok(Seed::from_bytes(arr))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use joy_crypt::identity::Keypair;
180    use joy_crypt::kdf::generate_salt;
181
182    #[test]
183    fn passphrase_wrap_roundtrip() {
184        let seed = Seed::generate();
185        let salt = generate_salt();
186        let wrap_hex =
187            wrap_seed_with_passphrase(&seed, "correct horse battery staple foo bar", &salt)
188                .unwrap();
189        let recovered =
190            unwrap_seed_with_passphrase(&wrap_hex, "correct horse battery staple foo bar", &salt)
191                .unwrap();
192        assert_eq!(seed.as_bytes(), recovered.as_bytes());
193    }
194
195    #[test]
196    fn passphrase_wrong_passphrase_rejected() {
197        let seed = Seed::generate();
198        let salt = generate_salt();
199        let wrap_hex =
200            wrap_seed_with_passphrase(&seed, "right pass words six total", &salt).unwrap();
201        let err = unwrap_seed_with_passphrase(&wrap_hex, "wrong pass words six total here", &salt)
202            .unwrap_err();
203        assert!(matches!(err, JoyError::AuthFailed(_)));
204    }
205
206    #[test]
207    fn recovery_wrap_roundtrip() {
208        let seed = Seed::generate();
209        let salt = generate_salt();
210        let recovery = RecoveryKey::generate();
211        let wrap_hex = wrap_seed_with_recovery(&seed, &recovery, &salt).unwrap();
212        let recovered = unwrap_seed_with_recovery(&wrap_hex, &recovery, &salt).unwrap();
213        assert_eq!(seed.as_bytes(), recovered.as_bytes());
214    }
215
216    #[test]
217    fn recovery_wrong_key_rejected() {
218        let seed = Seed::generate();
219        let salt = generate_salt();
220        let recovery = RecoveryKey::generate();
221        let wrap_hex = wrap_seed_with_recovery(&seed, &recovery, &salt).unwrap();
222        let other = RecoveryKey::generate();
223        let err = unwrap_seed_with_recovery(&wrap_hex, &other, &salt).unwrap_err();
224        assert!(matches!(err, JoyError::AuthFailed(_)));
225    }
226
227    #[test]
228    fn passphrase_change_preserves_keypair() {
229        // Wrap a seed, then re-wrap under a new passphrase; the keypair
230        // derived from the seed must be identical.
231        let seed = Seed::generate();
232        let kp_before = Keypair::from_seed(seed.as_bytes());
233
234        let salt = generate_salt();
235        let old_wrap =
236            wrap_seed_with_passphrase(&seed, "alpha bravo charlie delta echo foxtrot", &salt)
237                .unwrap();
238        let recovered =
239            unwrap_seed_with_passphrase(&old_wrap, "alpha bravo charlie delta echo foxtrot", &salt)
240                .unwrap();
241        let new_wrap =
242            wrap_seed_with_passphrase(&recovered, "yankee zulu papa quebec sierra tango", &salt)
243                .unwrap();
244        let after =
245            unwrap_seed_with_passphrase(&new_wrap, "yankee zulu papa quebec sierra tango", &salt)
246                .unwrap();
247        let kp_after = Keypair::from_seed(after.as_bytes());
248
249        assert_eq!(kp_before.public_key(), kp_after.public_key());
250    }
251
252    #[test]
253    fn recovery_key_display_roundtrip() {
254        let r = RecoveryKey::generate();
255        let s = r.to_display_string();
256        assert!(s.starts_with("joy_r_"));
257        let parsed = RecoveryKey::from_user_input(&s).unwrap();
258        assert_eq!(r.as_bytes(), parsed.as_bytes());
259    }
260
261    #[test]
262    fn recovery_key_accepts_bare_hex() {
263        let r = RecoveryKey::generate();
264        let bare = hex::encode(r.as_bytes());
265        let parsed = RecoveryKey::from_user_input(&bare).unwrap();
266        assert_eq!(r.as_bytes(), parsed.as_bytes());
267    }
268
269    #[test]
270    fn recovery_key_rejects_bad_input() {
271        assert!(RecoveryKey::from_user_input("zzz").is_err());
272        assert!(RecoveryKey::from_user_input("joy_r_00").is_err());
273    }
274}