1use cardanowall::sealed_poe::RecipientKeyBundle;
15use cardanowall::seed_derive::{
16 derive_ed25519_keypair, derive_mlkem768x25519_keypair, derive_x25519_keypair,
17};
18use clap::Args;
19
20use crate::secret::{resolve_secret_bytes, SecretArgs, SecretEnv, SecretKind};
21use crate::util::{hex_to_bytes, CliError};
22
23#[derive(Debug, Args, Clone, Default)]
26pub struct IdentitySource {
27 #[arg(long)]
30 pub seed: Option<String>,
31 #[arg(long = "seed-file")]
33 pub seed_file: Option<String>,
34 #[arg(long = "seed-stdin")]
36 pub seed_stdin: bool,
37 #[arg(long = "secret-key")]
40 pub secret_key: Option<String>,
41 #[arg(long = "secret-key-file")]
43 pub secret_key_file: Option<String>,
44 #[arg(long = "secret-key-stdin")]
46 pub secret_key_stdin: bool,
47}
48
49impl IdentitySource {
50 fn seed_args(&self) -> SecretArgs {
51 SecretArgs {
52 value: self.seed.clone(),
53 file: self.seed_file.clone(),
54 stdin: self.seed_stdin,
55 }
56 }
57
58 fn secret_key_args(&self) -> SecretArgs {
59 SecretArgs {
60 value: self.secret_key.clone(),
61 file: self.secret_key_file.clone(),
62 stdin: self.secret_key_stdin,
63 }
64 }
65
66 pub fn resolve(&self, cmd: &str, env: &dyn SecretEnv) -> Result<ResolvedIdentity, CliError> {
75 let seed_present =
76 self.seed_args().any_present() || env.var(SecretKind::Seed.env_var()).is_some();
77 let key_present = self.secret_key_args().any_present()
78 || env.var(SecretKind::RecipientKey.env_var()).is_some();
79
80 match (seed_present, key_present) {
81 (true, true) => Err(CliError::input(format!(
82 "{cmd}: exactly one of --seed / --secret-key MUST be supplied (got both)"
83 ))),
84 (true, false) => {
85 let bytes = resolve_secret_bytes(
86 SecretKind::Seed,
87 &self.seed_args(),
88 32,
89 true,
90 cmd,
91 env,
92 )?
93 .expect("required seed resolves or errors");
94 resolve_from_seed_bytes(&bytes)
95 }
96 (false, true) => {
97 let bytes = resolve_secret_bytes(
98 SecretKind::RecipientKey,
99 &self.secret_key_args(),
100 32,
101 true,
102 cmd,
103 env,
104 )?
105 .expect("required secret-key resolves or errors");
106 resolve_from_secret_key_bytes(&bytes)
107 }
108 (false, false) => Err(CliError::input(format!(
109 "{cmd}: exactly one of --seed / --secret-key MUST be supplied \
110 (also accepts --seed-file/--seed-stdin/CARDANOWALL_SEED or the secret-key variants)"
111 ))),
112 }
113 }
114}
115
116#[derive(Debug, Clone)]
118pub struct ResolvedIdentity {
119 pub x25519_private_key: Vec<u8>,
121 pub mlkem768x25519_secret_seed: Option<Vec<u8>>,
124 pub ed25519_public_key: Option<Vec<u8>>,
126}
127
128#[derive(Debug, Clone)]
130pub enum IdentityInput {
131 Seed(String),
133 SecretKey(String),
135}
136
137impl ResolvedIdentity {
138 #[must_use]
142 pub fn recipient_key_bundle(&self) -> RecipientKeyBundle {
143 RecipientKeyBundle {
144 x25519_private_keys: vec![self.x25519_private_key.clone()],
145 mlkem768x25519_secret_seeds: self
146 .mlkem768x25519_secret_seed
147 .clone()
148 .map(|s| vec![s])
149 .unwrap_or_default(),
150 }
151 }
152}
153
154pub fn resolve_identity(
161 seed: Option<&str>,
162 secret_key: Option<&str>,
163 cmd: &str,
164) -> Result<ResolvedIdentity, CliError> {
165 let input = pick_identity_input(seed, secret_key, cmd)?;
166 match input {
167 IdentityInput::Seed(hex) => resolve_from_seed_hex(&hex),
168 IdentityInput::SecretKey(hex) => resolve_from_secret_key_hex(&hex),
169 }
170}
171
172fn pick_identity_input(
174 seed: Option<&str>,
175 secret_key: Option<&str>,
176 cmd: &str,
177) -> Result<IdentityInput, CliError> {
178 match (seed, secret_key) {
179 (Some(s), None) => Ok(IdentityInput::Seed(s.to_string())),
180 (None, Some(k)) => Ok(IdentityInput::SecretKey(k.to_string())),
181 (None, None) => Err(CliError::input(format!(
182 "{cmd}: exactly one of --seed / --secret-key MUST be supplied"
183 ))),
184 (Some(_), Some(_)) => Err(CliError::input(format!(
185 "{cmd}: exactly one of --seed / --secret-key MUST be supplied (got both)"
186 ))),
187 }
188}
189
190fn resolve_from_seed_hex(seed_hex: &str) -> Result<ResolvedIdentity, CliError> {
191 let bytes =
192 hex_to_bytes(seed_hex).map_err(|e| CliError::input(format!("inbox: --seed {e}")))?;
193 if bytes.len() != 32 {
194 return Err(CliError::input(format!(
195 "inbox: seed MUST be exactly 32 bytes, got {}",
196 bytes.len()
197 )));
198 }
199 resolve_from_seed_bytes(&bytes)
200}
201
202fn resolve_from_seed_bytes(bytes: &[u8]) -> Result<ResolvedIdentity, CliError> {
204 let x25519 =
205 derive_x25519_keypair(bytes).map_err(|e| CliError::input(format!("inbox: --seed {e}")))?;
206 let ed25519 =
207 derive_ed25519_keypair(bytes).map_err(|e| CliError::input(format!("inbox: --seed {e}")))?;
208 let xwing = derive_mlkem768x25519_keypair(bytes)
209 .map_err(|e| CliError::input(format!("inbox: --seed {e}")))?;
210 Ok(ResolvedIdentity {
211 x25519_private_key: x25519.secret_key.to_vec(),
212 mlkem768x25519_secret_seed: Some(xwing.secret_seed.to_vec()),
213 ed25519_public_key: Some(ed25519.public_key.to_vec()),
214 })
215}
216
217fn resolve_from_secret_key_hex(secret_key_hex: &str) -> Result<ResolvedIdentity, CliError> {
218 if secret_key_hex.len() != 64
221 || !secret_key_hex
222 .bytes()
223 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
224 {
225 return Err(CliError::input(format!(
226 "inbox: --secret-key must be a 64-char lowercase-hex string; got \"{secret_key_hex}\""
227 )));
228 }
229 let bytes = hex_to_bytes(secret_key_hex)
230 .map_err(|e| CliError::input(format!("inbox: --secret-key {e}")))?;
231 resolve_from_secret_key_bytes(&bytes)
232}
233
234fn resolve_from_secret_key_bytes(bytes: &[u8]) -> Result<ResolvedIdentity, CliError> {
237 Ok(ResolvedIdentity {
238 x25519_private_key: bytes.to_vec(),
239 mlkem768x25519_secret_seed: None,
240 ed25519_public_key: None,
241 })
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn seed_path_yields_full_identity() {
250 let id = resolve_identity(Some(&"01".repeat(32)), None, "inbox sync").unwrap();
251 assert!(id.ed25519_public_key.is_some());
252 assert!(id.mlkem768x25519_secret_seed.is_some());
253 let bundle = id.recipient_key_bundle();
254 assert_eq!(bundle.x25519_private_keys.len(), 1);
255 assert_eq!(bundle.mlkem768x25519_secret_seeds.len(), 1);
256 }
257
258 #[test]
259 fn secret_key_path_has_no_ed25519_or_hybrid() {
260 let id = resolve_identity(None, Some(&"ab".repeat(32)), "inbox sync").unwrap();
261 assert!(id.ed25519_public_key.is_none());
262 assert!(id.mlkem768x25519_secret_seed.is_none());
263 let bundle = id.recipient_key_bundle();
264 assert!(bundle.mlkem768x25519_secret_seeds.is_empty());
265 }
266
267 #[test]
268 fn rejects_neither_or_both() {
269 assert_eq!(
270 resolve_identity(None, None, "inbox sync").unwrap_err().code,
271 4
272 );
273 assert_eq!(
274 resolve_identity(Some("a"), Some("b"), "inbox sync")
275 .unwrap_err()
276 .code,
277 4
278 );
279 }
280
281 #[test]
282 fn secret_key_rejects_uppercase() {
283 assert_eq!(
284 resolve_identity(None, Some(&"AB".repeat(32)), "inbox sync")
285 .unwrap_err()
286 .code,
287 4
288 );
289 }
290}