use cardanowall::sealed_poe::RecipientKeyBundle;
use cardanowall::seed_derive::{
derive_ed25519_keypair, derive_mlkem768x25519_keypair, derive_x25519_keypair,
};
use clap::Args;
use crate::secret::{resolve_secret_bytes, SecretArgs, SecretEnv, SecretKind};
use crate::util::{hex_to_bytes, CliError};
#[derive(Debug, Args, Clone, Default)]
pub struct IdentitySource {
#[arg(long)]
pub seed: Option<String>,
#[arg(long = "seed-file")]
pub seed_file: Option<String>,
#[arg(long = "seed-stdin")]
pub seed_stdin: bool,
#[arg(long = "secret-key")]
pub secret_key: Option<String>,
#[arg(long = "secret-key-file")]
pub secret_key_file: Option<String>,
#[arg(long = "secret-key-stdin")]
pub secret_key_stdin: bool,
}
impl IdentitySource {
fn seed_args(&self) -> SecretArgs {
SecretArgs {
value: self.seed.clone(),
file: self.seed_file.clone(),
stdin: self.seed_stdin,
}
}
fn secret_key_args(&self) -> SecretArgs {
SecretArgs {
value: self.secret_key.clone(),
file: self.secret_key_file.clone(),
stdin: self.secret_key_stdin,
}
}
pub fn resolve(&self, cmd: &str, env: &dyn SecretEnv) -> Result<ResolvedIdentity, CliError> {
let seed_present =
self.seed_args().any_present() || env.var(SecretKind::Seed.env_var()).is_some();
let key_present = self.secret_key_args().any_present()
|| env.var(SecretKind::RecipientKey.env_var()).is_some();
match (seed_present, key_present) {
(true, true) => Err(CliError::input(format!(
"{cmd}: exactly one of --seed / --secret-key MUST be supplied (got both)"
))),
(true, false) => {
let bytes = resolve_secret_bytes(
SecretKind::Seed,
&self.seed_args(),
32,
true,
cmd,
env,
)?
.expect("required seed resolves or errors");
resolve_from_seed_bytes(&bytes)
}
(false, true) => {
let bytes = resolve_secret_bytes(
SecretKind::RecipientKey,
&self.secret_key_args(),
32,
true,
cmd,
env,
)?
.expect("required secret-key resolves or errors");
resolve_from_secret_key_bytes(&bytes)
}
(false, false) => Err(CliError::input(format!(
"{cmd}: exactly one of --seed / --secret-key MUST be supplied \
(also accepts --seed-file/--seed-stdin/CARDANOWALL_SEED or the secret-key variants)"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedIdentity {
pub x25519_private_key: Vec<u8>,
pub mlkem768x25519_secret_seed: Option<Vec<u8>>,
pub ed25519_public_key: Option<Vec<u8>>,
}
#[derive(Debug, Clone)]
pub enum IdentityInput {
Seed(String),
SecretKey(String),
}
impl ResolvedIdentity {
#[must_use]
pub fn recipient_key_bundle(&self) -> RecipientKeyBundle {
RecipientKeyBundle {
x25519_private_keys: vec![self.x25519_private_key.clone()],
mlkem768x25519_secret_seeds: self
.mlkem768x25519_secret_seed
.clone()
.map(|s| vec![s])
.unwrap_or_default(),
}
}
}
pub fn resolve_identity(
seed: Option<&str>,
secret_key: Option<&str>,
cmd: &str,
) -> Result<ResolvedIdentity, CliError> {
let input = pick_identity_input(seed, secret_key, cmd)?;
match input {
IdentityInput::Seed(hex) => resolve_from_seed_hex(&hex),
IdentityInput::SecretKey(hex) => resolve_from_secret_key_hex(&hex),
}
}
fn pick_identity_input(
seed: Option<&str>,
secret_key: Option<&str>,
cmd: &str,
) -> Result<IdentityInput, CliError> {
match (seed, secret_key) {
(Some(s), None) => Ok(IdentityInput::Seed(s.to_string())),
(None, Some(k)) => Ok(IdentityInput::SecretKey(k.to_string())),
(None, None) => Err(CliError::input(format!(
"{cmd}: exactly one of --seed / --secret-key MUST be supplied"
))),
(Some(_), Some(_)) => Err(CliError::input(format!(
"{cmd}: exactly one of --seed / --secret-key MUST be supplied (got both)"
))),
}
}
fn resolve_from_seed_hex(seed_hex: &str) -> Result<ResolvedIdentity, CliError> {
let bytes =
hex_to_bytes(seed_hex).map_err(|e| CliError::input(format!("inbox: --seed {e}")))?;
if bytes.len() != 32 {
return Err(CliError::input(format!(
"inbox: seed MUST be exactly 32 bytes, got {}",
bytes.len()
)));
}
resolve_from_seed_bytes(&bytes)
}
fn resolve_from_seed_bytes(bytes: &[u8]) -> Result<ResolvedIdentity, CliError> {
let x25519 =
derive_x25519_keypair(bytes).map_err(|e| CliError::input(format!("inbox: --seed {e}")))?;
let ed25519 =
derive_ed25519_keypair(bytes).map_err(|e| CliError::input(format!("inbox: --seed {e}")))?;
let xwing = derive_mlkem768x25519_keypair(bytes)
.map_err(|e| CliError::input(format!("inbox: --seed {e}")))?;
Ok(ResolvedIdentity {
x25519_private_key: x25519.secret_key.to_vec(),
mlkem768x25519_secret_seed: Some(xwing.secret_seed.to_vec()),
ed25519_public_key: Some(ed25519.public_key.to_vec()),
})
}
fn resolve_from_secret_key_hex(secret_key_hex: &str) -> Result<ResolvedIdentity, CliError> {
if secret_key_hex.len() != 64
|| !secret_key_hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err(CliError::input(format!(
"inbox: --secret-key must be a 64-char lowercase-hex string; got \"{secret_key_hex}\""
)));
}
let bytes = hex_to_bytes(secret_key_hex)
.map_err(|e| CliError::input(format!("inbox: --secret-key {e}")))?;
resolve_from_secret_key_bytes(&bytes)
}
fn resolve_from_secret_key_bytes(bytes: &[u8]) -> Result<ResolvedIdentity, CliError> {
Ok(ResolvedIdentity {
x25519_private_key: bytes.to_vec(),
mlkem768x25519_secret_seed: None,
ed25519_public_key: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seed_path_yields_full_identity() {
let id = resolve_identity(Some(&"01".repeat(32)), None, "inbox sync").unwrap();
assert!(id.ed25519_public_key.is_some());
assert!(id.mlkem768x25519_secret_seed.is_some());
let bundle = id.recipient_key_bundle();
assert_eq!(bundle.x25519_private_keys.len(), 1);
assert_eq!(bundle.mlkem768x25519_secret_seeds.len(), 1);
}
#[test]
fn secret_key_path_has_no_ed25519_or_hybrid() {
let id = resolve_identity(None, Some(&"ab".repeat(32)), "inbox sync").unwrap();
assert!(id.ed25519_public_key.is_none());
assert!(id.mlkem768x25519_secret_seed.is_none());
let bundle = id.recipient_key_bundle();
assert!(bundle.mlkem768x25519_secret_seeds.is_empty());
}
#[test]
fn rejects_neither_or_both() {
assert_eq!(
resolve_identity(None, None, "inbox sync").unwrap_err().code,
4
);
assert_eq!(
resolve_identity(Some("a"), Some("b"), "inbox sync")
.unwrap_err()
.code,
4
);
}
#[test]
fn secret_key_rejects_uppercase() {
assert_eq!(
resolve_identity(None, Some(&"AB".repeat(32)), "inbox sync")
.unwrap_err()
.code,
4
);
}
}