1use joy_crypt::kdf::{derive_argon2id, DerivedKey, Salt};
20use joy_crypt::wrap;
21use rand::RngCore;
22use zeroize::Zeroizing;
23
24use crate::error::JoyError;
25
26pub 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 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 pub fn to_display_string(&self) -> String {
46 format!("joy_r_{}", hex::encode(self.0.as_ref()))
47 }
48
49 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 pub fn as_bytes(&self) -> &[u8; 32] {
67 &self.0
68 }
69}
70
71pub 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 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 pub fn from_bytes(bytes: [u8; 32]) -> Self {
91 Self(Zeroizing::new(bytes))
92 }
93
94 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
105pub 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
117pub 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
129pub 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
147pub 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
157pub 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 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}