use cardanowall::hash::sha256;
use cardanowall::recipient::{encode_age_x25519_recipient, encode_age_xwing_recipient};
use cardanowall::seed_derive::{
derive_ed25519_keypair, derive_mlkem768x25519_keypair, derive_x25519_keypair,
};
use clap::Args;
use serde::Serialize;
use crate::secret::{resolve_secret_bytes, SecretArgs, SecretEnv, SecretKind, SystemSecretEnv};
use crate::util::{bytes_to_hex, CliError};
const MASTER_SEED_BYTES: usize = 32;
const XWING_HEX_ABBREV_HEAD: usize = 16;
#[derive(Debug, Args)]
pub struct IdentityArgs {
#[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)]
pub json: bool,
}
impl IdentityArgs {
fn secret_args(&self) -> SecretArgs {
SecretArgs {
value: self.seed.clone(),
file: self.seed_file.clone(),
stdin: self.seed_stdin,
}
}
}
#[derive(Debug, Serialize)]
pub struct IdentityOutcome {
pub fingerprint: String,
pub ed25519_pubkey_hex: String,
pub x25519_pubkey_hex: String,
pub xwing_pubkey_hex: String,
pub age_recipient: String,
pub age1pqc_recipient: String,
}
fn display_fingerprint(ed25519_public_key: &[u8]) -> String {
let digest = sha256(ed25519_public_key);
let hex = bytes_to_hex(&digest[..8]); format!(
"{}-{}-{}-{}",
&hex[0..4],
&hex[4..8],
&hex[8..12],
&hex[12..16]
)
}
pub fn build_identity_outcome(seed: &[u8]) -> Result<IdentityOutcome, CliError> {
let ed25519 =
derive_ed25519_keypair(seed).map_err(|e| CliError::input(format!("identity: {e}")))?;
let x25519 =
derive_x25519_keypair(seed).map_err(|e| CliError::input(format!("identity: {e}")))?;
let xwing = derive_mlkem768x25519_keypair(seed)
.map_err(|e| CliError::input(format!("identity: {e}")))?;
let age_recipient = encode_age_x25519_recipient(&x25519.public_key)
.map_err(|e| CliError::input(format!("identity: {e}")))?;
let age1pqc_recipient = encode_age_xwing_recipient(&xwing.public_key)
.map_err(|e| CliError::input(format!("identity: {e}")))?;
Ok(IdentityOutcome {
fingerprint: display_fingerprint(&ed25519.public_key),
ed25519_pubkey_hex: bytes_to_hex(&ed25519.public_key),
x25519_pubkey_hex: bytes_to_hex(&x25519.public_key),
xwing_pubkey_hex: bytes_to_hex(&xwing.public_key),
age_recipient,
age1pqc_recipient,
})
}
fn resolve_seed(args: &IdentityArgs, env: &dyn SecretEnv) -> Result<Vec<u8>, CliError> {
resolve_secret_bytes(
SecretKind::Seed,
&args.secret_args(),
MASTER_SEED_BYTES,
true,
"identity",
env,
)
.map(|opt| opt.expect("a required seed resolves to Some or errors"))
}
fn abbreviate(hex: &str, head: usize) -> String {
if hex.len() <= head * 2 + 1 {
return hex.to_string();
}
format!(
"{}…{} ({} bytes)",
&hex[..head],
&hex[hex.len() - head..],
hex.len() / 2
)
}
fn emit(outcome: &IdentityOutcome, json: bool) {
if json {
println!(
"{}",
serde_json::to_string(outcome).expect("IdentityOutcome serialises")
);
return;
}
println!("fingerprint: {}", outcome.fingerprint);
println!("ed25519: {}", outcome.ed25519_pubkey_hex);
println!("x25519: {}", outcome.x25519_pubkey_hex);
println!(
"x-wing: {}",
abbreviate(&outcome.xwing_pubkey_hex, XWING_HEX_ABBREV_HEAD)
);
println!("age: {}", outcome.age_recipient);
println!("age1pqc: {}", outcome.age1pqc_recipient);
}
pub fn run(args: IdentityArgs) -> Result<(), CliError> {
let seed = resolve_seed(&args, &SystemSecretEnv)?;
let outcome = build_identity_outcome(&seed)?;
emit(&outcome, args.json);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::secret::test_support::FakeSecretEnv;
fn args_with_seed(seed: Option<&str>) -> IdentityArgs {
IdentityArgs {
seed: seed.map(str::to_string),
seed_file: None,
seed_stdin: false,
json: false,
}
}
#[test]
fn derivation_is_deterministic() {
let seed = [7u8; 32];
let a = build_identity_outcome(&seed).unwrap();
let b = build_identity_outcome(&seed).unwrap();
assert_eq!(a.fingerprint, b.fingerprint);
assert_eq!(a.ed25519_pubkey_hex, b.ed25519_pubkey_hex);
assert_eq!(a.age_recipient, b.age_recipient);
assert!(a.age_recipient.starts_with("age1"));
assert!(a.age1pqc_recipient.starts_with("age1pqc1"));
}
#[test]
fn rejects_short_seed() {
let env = FakeSecretEnv::default();
assert_eq!(
resolve_seed(&args_with_seed(Some("dead")), &env)
.unwrap_err()
.code,
4
);
}
#[test]
fn rejects_missing_seed_on_non_tty() {
let env = FakeSecretEnv::default();
assert_eq!(
resolve_seed(&args_with_seed(None), &env).unwrap_err().code,
4
);
}
#[test]
fn resolves_seed_via_argv() {
let env = FakeSecretEnv::default();
let seed = resolve_seed(&args_with_seed(Some(&"ab".repeat(32))), &env).unwrap();
assert_eq!(seed.len(), 32);
}
#[test]
fn fingerprint_groups_16_hex_chars() {
let outcome = build_identity_outcome(&[1u8; 32]).unwrap();
assert_eq!(outcome.fingerprint.len(), 19);
assert_eq!(outcome.fingerprint.matches('-').count(), 3);
}
}