use crate::{bytes_to_hex_str, fmt_lh, key_write_path, name_is_valid, registry, resolve_key_read_path, secure_key_file, wallet};
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
pub(crate) const LINK_USAGE: &str = "\
usage: localharness link --as <name> <adopt-url-or-ciphertext> [--code <CODE>]
Adopt a FUNDED web wallet's seed into this terminal identity so the CLI
inherits its $LH — the terminal mirror of the browser's QR seed-adoption.
1. In the funded identity's browser (admin -> \"add a device\"), get the
one-time CODE and the ?adopt=1#s=<ct> link/QR it shows.
2. localharness link --as <name> '<that link>' --code <CODE>
(the ciphertext alone, the bare #s=<hex>, or the full URL all work; the
--code may instead be typed when prompted).
Writes <name>'s key file (perms-locked); the CLI then acts as that identity.";
const MAX_PHRASE_BYTES: usize = 256;
pub(crate) fn extract_adopt_ciphertext(input: &str) -> Option<String> {
let s = input.trim();
if s.is_empty() {
return None;
}
let candidate = s
.rsplit_once("s=")
.map(|(_, v)| v)
.unwrap_or(s)
.split(['&', ' ', '\t', '\n', '#'])
.next()
.unwrap_or("")
.trim()
.trim_start_matches("0x")
.trim_start_matches("0X");
if candidate.is_empty() || candidate.len() % 2 != 0 {
return None;
}
if !candidate.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
Some(candidate.to_string())
}
pub(crate) fn decrypt_adopt(ciphertext: &[u8], code: &str) -> Result<String, String> {
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
if ciphertext.len() < NONCE_LEN + TAG_LEN {
return Err("ciphertext too short — not a valid adopt link".to_string());
}
let key = wallet::adopt_code_key(code);
let cipher = Aes256Gcm::new((&key).into());
let (nonce_bytes, ct) = ciphertext.split_at(NONCE_LEN);
let mut nonce_arr = [0u8; NONCE_LEN];
nonce_arr.copy_from_slice(nonce_bytes);
let nonce = Nonce::from(nonce_arr);
let plain = cipher
.decrypt(&nonce, ct)
.map_err(|_| "wrong code, or this is not an adopt link (GCM auth failed)".to_string())?;
if plain.len() > MAX_PHRASE_BYTES {
return Err("decrypted payload is not a seed phrase".to_string());
}
let phrase =
String::from_utf8(plain).map_err(|_| "decrypted payload is not text".to_string())?;
Ok(phrase)
}
pub(crate) async fn link(args: &[String]) -> i32 {
let mut as_name: Option<String> = None;
let mut code: Option<String> = None;
let mut payload: Option<String> = None;
let mut overwrite = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--as" => match args.get(i + 1) {
Some(n) => {
as_name = Some(n.clone());
i += 2;
}
None => {
eprintln!("--as needs a name\n{LINK_USAGE}");
return 2;
}
},
"--code" => match args.get(i + 1) {
Some(c) => {
code = Some(c.clone());
i += 2;
}
None => {
eprintln!("--code needs a value\n{LINK_USAGE}");
return 2;
}
},
"--force" => {
overwrite = true;
i += 1;
}
other if payload.is_none() => {
payload = Some(other.to_string());
i += 1;
}
other => {
eprintln!("unexpected argument '{other}'\n{LINK_USAGE}");
return 2;
}
}
}
let Some(name) = as_name else {
eprintln!("link needs --as <name> to name the local key\n{LINK_USAGE}");
return 2;
};
if !name_is_valid(&name) {
eprintln!("invalid name '{name}' — use 1-63 chars of a-z, 0-9, hyphen");
return 2;
}
if !overwrite {
if let Some(existing) = resolve_key_read_path(&name) {
eprintln!(
"an identity key for '{name}' already exists at {existing} — \
pass --force to replace it with the linked seed"
);
return 1;
}
}
let Some(payload) = payload else {
eprintln!("link needs the ?adopt=1#s=<ct> link (or its ciphertext)\n{LINK_USAGE}");
return 2;
};
let Some(ct_hex) = extract_adopt_ciphertext(&payload) else {
eprintln!("could not find an adopt ciphertext in the input — paste the full ?adopt=1#s=<…> link, or the #s=<hex> fragment");
return 2;
};
let ct = match localharness::encoding::hex_to_bytes(&ct_hex) {
Ok(b) if !b.is_empty() => b,
_ => {
eprintln!("the adopt link's ciphertext is not valid hex");
return 2;
}
};
let code = match code {
Some(c) => c,
None => match prompt_code() {
Ok(c) => c,
Err(e) => {
eprintln!("{e}\n{LINK_USAGE}");
return 2;
}
},
};
if code.trim().is_empty() {
eprintln!("the one-time code is empty\n{LINK_USAGE}");
return 2;
}
let phrase = match decrypt_adopt(&ct, &code) {
Ok(p) => p,
Err(e) => {
eprintln!("link failed: {e}");
return 1;
}
};
let mnemonic = match wallet::mnemonic_from_phrase(phrase.trim()) {
Ok(m) => m,
Err(_) => {
eprintln!("link failed: the decrypted payload is not a valid seed phrase (wrong code?)");
return 1;
}
};
let signer = wallet::signer_from_mnemonic(&mnemonic);
let addr = bytes_to_hex_str(&wallet::address(&signer));
let priv_hex = format!("0x{}", localharness::encoding::bytes_to_hex(&signer.to_bytes()));
let key_file = key_write_path(&name);
if let Err(e) = std::fs::write(&key_file, format!("{priv_hex}\n")) {
eprintln!("could not persist key to {key_file}: {e}");
return 1;
}
secure_key_file(&key_file);
println!("✓ linked '{name}' to the web identity {addr}");
println!(" key written to {key_file}");
let bal = registry::token_balance_of(&addr).await;
println!("{}", link_balance_line(&bal));
println!(" try: localharness credits --as {name}");
0
}
pub(crate) fn link_balance_line(bal: &Result<u128, String>) -> String {
match bal {
Ok(b) if *b > 0 => {
format!(" wallet balance: {} (inherited from the web wallet)", fmt_lh(*b))
}
Ok(_) => " the CLI now shares this identity's wallet + $LH".to_string(),
Err(_) => {
" balance not yet confirmed on-chain; check with `localharness credits --as <name>`"
.to_string()
}
}
}
fn prompt_code() -> Result<String, String> {
use std::io::{BufRead, Write};
print!("one-time code (from the browser): ");
std::io::stdout().flush().ok();
let mut line = String::new();
std::io::stdin()
.lock()
.read_line(&mut line)
.map_err(|e| format!("could not read the code: {e}"))?;
Ok(line.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_handles_url_fragment_and_raw_hex() {
assert_eq!(
extract_adopt_ciphertext("https://localharness.xyz/?adopt=1#s=deadbeef").as_deref(),
Some("deadbeef")
);
assert_eq!(extract_adopt_ciphertext("#s=deadbeef").as_deref(), Some("deadbeef"));
assert_eq!(extract_adopt_ciphertext("s=deadbeef").as_deref(), Some("deadbeef"));
assert_eq!(extract_adopt_ciphertext("deadbeef").as_deref(), Some("deadbeef"));
assert_eq!(extract_adopt_ciphertext("0xDEADBEEF").as_deref(), Some("DEADBEEF"));
assert_eq!(extract_adopt_ciphertext(" #s=deadbeef \n").as_deref(), Some("deadbeef"));
assert_eq!(
extract_adopt_ciphertext("https://x/?adopt=1#s=deadbeef&foo=1").as_deref(),
Some("deadbeef")
);
}
#[test]
fn extract_rejects_non_hex_and_empty() {
assert_eq!(extract_adopt_ciphertext(""), None);
assert_eq!(extract_adopt_ciphertext(" "), None);
assert_eq!(extract_adopt_ciphertext("abc"), None);
assert_eq!(extract_adopt_ciphertext("#s=nothex!!"), None);
assert_eq!(extract_adopt_ciphertext("hello world"), None);
}
#[test]
fn decrypt_round_trips_a_browser_style_sealed_phrase() {
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
let code = "AB12CD";
let phrase = "test test test test test test test test test test test junk";
let key = wallet::adopt_code_key(code);
let cipher = Aes256Gcm::new((&key).into());
let nonce_bytes = [9u8; 12];
let nonce = Nonce::from(nonce_bytes);
let ct = cipher.encrypt(&nonce, phrase.as_bytes()).unwrap();
let mut sealed = Vec::new();
sealed.extend_from_slice(&nonce_bytes);
sealed.extend_from_slice(&ct);
assert_eq!(decrypt_adopt(&sealed, code).unwrap(), phrase);
assert_eq!(decrypt_adopt(&sealed, "ab12cd").unwrap(), phrase);
}
#[test]
fn decrypt_rejects_wrong_code_and_short_input() {
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
let key = wallet::adopt_code_key("RIGHT1");
let cipher = Aes256Gcm::new((&key).into());
let nonce_bytes = [3u8; 12];
let ct = cipher.encrypt(&Nonce::from(nonce_bytes), b"hello".as_ref()).unwrap();
let mut sealed = Vec::new();
sealed.extend_from_slice(&nonce_bytes);
sealed.extend_from_slice(&ct);
assert!(decrypt_adopt(&sealed, "WRONG1").is_err());
assert!(decrypt_adopt(&[0u8; 10], "RIGHT1").is_err());
let mut bad = sealed.clone();
let last = bad.len() - 1;
bad[last] ^= 0x01;
assert!(decrypt_adopt(&bad, "RIGHT1").is_err());
}
#[test]
fn link_balance_line_distinguishes_zero_from_error() {
let zero = link_balance_line(&Ok(0));
let err = link_balance_line(&Err("net".into()));
assert_ne!(zero, err);
assert!(err.contains("not yet confirmed"));
assert!(link_balance_line(&Ok(5_000_000_000_000_000_000)).contains("wallet balance"));
}
}