Skip to main content

cardanowall_cli/inbox/
identity.rs

1//! Identity resolution for the inbox subcommands — raw-seed-first, no envelope.
2//!
3//! Two input paths:
4//!
5//! - `--seed <hex32>`        → the 32-byte master identity seed. Runs the full
6//!   derivation (Ed25519 + X25519 + X-Wing), so this is the only path that can
7//!   locate the bookmark file (keyed by the Ed25519 public key) AND read hybrid
8//!   (`mlkem768x25519`) sealed records.
9//! - `--secret-key <hex32>`  → raw X25519 secret bytes (testing + power users).
10//!   The Ed25519 pubkey is NOT recoverable from this path, so the
11//!   bookmark-locating commands need the seed path; this surface returns `None`
12//!   for the Ed25519 fields and callers must check.
13
14use 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/// The identity-input flags shared by every inbox verb: exactly one of the seed
24/// family or the secret-key family, each with raw / `*-file` / `*-stdin` variants.
25#[derive(Debug, Args, Clone, Default)]
26pub struct IdentitySource {
27    /// 32-byte master identity seed (hex). INSECURE on argv (shell history / ps /
28    /// CI logs); prefer --seed-file / --seed-stdin / CARDANOWALL_SEED.
29    #[arg(long)]
30    pub seed: Option<String>,
31    /// read the seed from a file (trailing whitespace trimmed).
32    #[arg(long = "seed-file")]
33    pub seed_file: Option<String>,
34    /// read the seed from stdin (also `--seed -`).
35    #[arg(long = "seed-stdin")]
36    pub seed_stdin: bool,
37    /// X25519 identity private key as 64-char lowercase hex. INSECURE on argv;
38    /// prefer --secret-key-file / --secret-key-stdin / CARDANOWALL_RECIPIENT_KEY.
39    #[arg(long = "secret-key")]
40    pub secret_key: Option<String>,
41    /// read the X25519 secret key from a file.
42    #[arg(long = "secret-key-file")]
43    pub secret_key_file: Option<String>,
44    /// read the X25519 secret key from stdin.
45    #[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    /// Resolve to exactly one identity, choosing the family the user supplied and
67    /// routing its value through the shared secret layer (file > stdin > argv >
68    /// env > prompt-on-TTY). Rejects supplying both families, or neither.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`CliError`] (exit `4`) when neither or both families are present,
73    /// or the chosen value is malformed / wrong length.
74    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/// A resolved inbox identity.
117#[derive(Debug, Clone)]
118pub struct ResolvedIdentity {
119    /// The raw X25519 private key (always present).
120    pub x25519_private_key: Vec<u8>,
121    /// The X-Wing secret seed for hybrid records; `None` on the `--secret-key`
122    /// path (no seed to derive it from, so hybrid records cleanly non-match).
123    pub mlkem768x25519_secret_seed: Option<Vec<u8>>,
124    /// The Ed25519 public key; `None` on the `--secret-key` path.
125    pub ed25519_public_key: Option<Vec<u8>>,
126}
127
128/// The identity input selection: exactly one of seed / secret-key.
129#[derive(Debug, Clone)]
130pub enum IdentityInput {
131    /// A 32-byte master identity seed (hex).
132    Seed(String),
133    /// A raw X25519 private key as 64-char lowercase hex.
134    SecretKey(String),
135}
136
137impl ResolvedIdentity {
138    /// Assemble the unified [`RecipientKeyBundle`] the trial-decrypt / unwrap
139    /// dispatch consumes. The single active identity contributes a one-element
140    /// X25519 chain plus, when seed-derived, a one-element X-Wing seed list.
141    #[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
154/// Resolve an identity from exactly one of `--seed` / `--secret-key`.
155///
156/// # Errors
157///
158/// Returns [`CliError`] (exit `4`) when neither or both are supplied, or the
159/// supplied value is malformed / the wrong length.
160pub 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
172/// Pick exactly one identity input, rejecting "none" and "both".
173fn 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
202/// Derive the full identity (Ed25519 + X25519 + X-Wing) from a 32-byte seed.
203fn 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    // Enforce the strict lowercase-hex shape the reference CLI requires for this
219    // power-user path (no `0x` prefix, no uppercase).
220    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
234/// Build an X25519-only identity from 32 raw secret-key bytes (no seed → no
235/// Ed25519 derivation, no X-Wing hybrid secret).
236fn 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}