use std::path::{Path, PathBuf};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
use chrono::Utc;
use dialoguer::Input;
use zeroize::Zeroizing;
use crate::acl::{AclEntry, Role, store_acl_entry};
use crate::cli_store::CliStore;
use crate::config::AppConfig;
use crate::contexts::{self, get_context};
use crate::keys;
use crate::keys::seed_store::create_seed_store;
use crate::keys::seeds::{get_active_seed_id, load_seed_bytes};
use crate::keys::{KeyOrigin, KeyRecord, KeyStatus, KeyType};
pub struct CreateDidKeyArgs {
pub config_path: Option<PathBuf>,
pub context: String,
pub admin: bool,
pub label: Option<String>,
pub private_key_file: Option<PathBuf>,
}
pub async fn run_create_did_key(args: CreateDidKeyArgs) -> Result<(), Box<dyn std::error::Error>> {
let config = AppConfig::load(args.config_path)?;
let cs = CliStore::open(&config).await?;
let keys_ks = cs.keyspace(crate::keyspaces::KEYS)?;
let contexts_ks = cs.keyspace(crate::keyspaces::CONTEXTS)?;
let seed_store = create_seed_store(&config)?;
let active_seed_id = get_active_seed_id(&keys_ks).await?;
let seed = load_seed_bytes(&keys_ks, &*seed_store, Some(active_seed_id)).await?;
let ctx = match get_context(&contexts_ks, &args.context).await? {
Some(ctx) => ctx,
None => {
eprintln!("Context '{}' does not exist.", args.context);
let name: String = Input::new()
.with_prompt("Create it with name")
.default(args.context.clone())
.interact_text()?;
let ctx = contexts::create_context(&contexts_ks, &args.context, &name).await?;
eprintln!("Created context: {} ({})", ctx.id, ctx.base_path);
ctx
}
};
let label = args.label.as_deref().unwrap_or("did:key");
let (did, private_key_multibase) = match args.private_key_file.as_deref() {
Some(path) => import_ed25519_did_key(&cs, &keys_ks, &seed, &ctx.id, label, path).await?,
None => {
keys::derive_and_store_did_key(
&seed,
&ctx.base_path,
&ctx.id,
label,
&keys_ks,
Some(active_seed_id),
)
.await?
}
};
if args.admin {
let acl_ks = cs.keyspace(crate::keyspaces::ACL)?;
let entry = AclEntry::new(did.clone(), Role::Admin, "cli:create-did-key")
.with_label(args.label.clone())
.with_contexts(vec![args.context.clone()]);
store_acl_entry(&acl_ks, &entry).await?;
eprintln!(
"ACL entry created: {} (admin, context: {})",
did, args.context
);
}
cs.persist().await?;
eprintln!("DID: {did}");
if args.admin {
let vta_did = config.vta_did.unwrap_or_default();
let mut bundle = serde_json::json!({
"did": did,
"privateKeyMultibase": private_key_multibase,
"vtaDid": vta_did,
});
if let Some(url) = &config.public_url {
bundle["vtaUrl"] = serde_json::json!(url);
}
let bundle_json = serde_json::to_string(&bundle)?;
let credential = BASE64.encode(bundle_json.as_bytes());
eprintln!();
eprintln!("Credential:");
println!("{credential}");
}
Ok(())
}
fn read_private_key_file(path: &Path) -> Result<Zeroizing<[u8; 32]>, Box<dyn std::error::Error>> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
let mode = meta.permissions().mode() & 0o077;
if mode != 0 {
eprintln!(
"\x1b[1;33mwarning:\x1b[0m {} is readable beyond its owner (mode {:o}) — \
private key material should be 0600",
path.display(),
meta.permissions().mode() & 0o777
);
}
}
}
let text = Zeroizing::new(
std::fs::read_to_string(path)
.map_err(|e| format!("cannot read --private-key-file {}: {e}", path.display()))?,
);
let decoded = Zeroizing::new(
hex::decode(text.trim())
.map_err(|e| format!("{} does not contain valid hex: {e}", path.display()))?,
);
if decoded.len() != 32 {
return Err(format!(
"{} must contain 32 bytes (64 hex chars); got {} bytes",
path.display(),
decoded.len()
)
.into());
}
let mut key_bytes = Zeroizing::new([0u8; 32]);
key_bytes.copy_from_slice(&decoded);
Ok(key_bytes)
}
async fn import_ed25519_did_key(
cs: &CliStore,
keys_ks: &crate::store::KeyspaceHandle,
seed: &[u8],
context_id: &str,
label: &str,
path: &Path,
) -> Result<(String, String), Box<dyn std::error::Error>> {
let key_bytes = read_private_key_file(path)?;
let (did, key_id, multibase_pubkey) = ed25519_did_key_ids(&key_bytes);
let private_key_multibase = keys::encode_private_multibase(&KeyType::Ed25519, &*key_bytes);
let now = Utc::now();
let record = KeyRecord {
exportable: None,
key_id: key_id.clone(),
derivation_path: String::new(),
key_type: KeyType::Ed25519,
status: KeyStatus::Active,
public_key: multibase_pubkey.clone(),
label: Some(label.to_string()),
context_id: Some(context_id.to_string()),
seed_id: None,
origin: KeyOrigin::Imported,
created_at: now,
updated_at: now,
};
keys_ks.insert(keys::store_key(&key_id), &record).await?;
let imported_ks = cs.keyspace(crate::keyspaces::IMPORTED_SECRETS)?;
keys::imported::store_secret(&imported_ks, keys_ks, seed, &key_id, "ed25519", &*key_bytes)
.await?;
eprintln!("Imported external Ed25519 signing key as {key_id}");
Ok((did, private_key_multibase))
}
fn ed25519_did_key_ids(key_bytes: &[u8; 32]) -> (String, String, String) {
let signing_key = ed25519_dalek::SigningKey::from_bytes(key_bytes);
let public_key = signing_key.verifying_key().to_bytes();
let multibase_pubkey = keys::ed25519_multibase_pubkey(&public_key);
let did = format!("did:key:{multibase_pubkey}");
let key_id = format!("{did}#{multibase_pubkey}");
(did, key_id, multibase_pubkey)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ed25519_did_key_ids_is_deterministic_and_well_formed() {
let key = [7u8; 32];
let (did, key_id, mb) = ed25519_did_key_ids(&key);
assert!(did.starts_with("did:key:z6Mk"), "unexpected did: {did}");
assert_eq!(key_id, format!("{did}#{mb}"));
let (did2, key_id2, _) = ed25519_did_key_ids(&key);
assert_eq!(did, did2);
assert_eq!(key_id, key_id2);
let (did_other, _, _) = ed25519_did_key_ids(&[9u8; 32]);
assert_ne!(did, did_other);
}
#[test]
fn read_private_key_file_accepts_64_hex_chars_with_trailing_newline() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("key.hex");
std::fs::write(&path, format!("{}\n", hex::encode([3u8; 32]))).unwrap();
let key = read_private_key_file(&path).expect("valid key file");
assert_eq!(*key, [3u8; 32]);
}
#[test]
fn read_private_key_file_rejects_wrong_length_and_bad_hex() {
let dir = tempfile::TempDir::new().unwrap();
let short = dir.path().join("short.hex");
std::fs::write(&short, hex::encode([1u8; 16])).unwrap();
let err = read_private_key_file(&short).expect_err("16 bytes must be rejected");
assert!(err.to_string().contains("32 bytes"), "got: {err}");
let bad = dir.path().join("bad.hex");
std::fs::write(&bad, "not hex at all").unwrap();
let err = read_private_key_file(&bad).expect_err("non-hex must be rejected");
assert!(err.to_string().contains("valid hex"), "got: {err}");
let missing = dir.path().join("nope.hex");
let err = read_private_key_file(&missing).expect_err("missing file must be rejected");
assert!(err.to_string().contains("cannot read"), "got: {err}");
}
}