use std::collections::{HashMap, HashSet};
use std::fs;
use std::fs::File;
use std::io::BufReader;
use crate::capability::KeyFp;
use crate::error::{Error, Result};
use crate::etree::{self, ParseOps, TextNode, TextTree};
use crate::ledger::{AnchorHash, SignedAnchor, SignerId};
use crate::pki::{self, SigAlgKind};
use super::{CommonArgs, MigrateKeysSubcmd, apply_common, resolve_policy};
struct Migration {
from: SigAlgKind,
to: SigAlgKind,
old_fp: KeyFp,
old_pub_pem: String,
new_priv_pem: String,
new_pub_pem: String,
new_signer: SignerId,
}
pub(super) fn run(common: CommonArgs, a: MigrateKeysSubcmd) -> Result<()> {
if a.files.is_empty() {
return Err(Error::InvalidArg {
arg: "FILE",
reason: "migrate-keys needs at least one FILE".to_string(),
});
}
if let Some(f) = a.files.iter().find(|f| f.as_str() == "-") {
return Err(Error::InvalidArg {
arg: "FILE",
reason: format!(
"stdin ('{f}') not supported: the rewrite must be written back in place"
),
});
}
let from: SigAlgKind = a.from.parse()?;
let to: SigAlgKind = a.to.parse()?;
if from == to {
return Err(Error::InvalidArg {
arg: "--to",
reason: format!("--from and --to are both {}", from.name()),
});
}
let old_pub_pem = fs::read_to_string(&a.old_key)?;
let new_priv_pem = fs::read_to_string(&a.new_key)?;
let migration = build_migration(from, to, &old_pub_pem, &new_priv_pem)?;
let policy = resolve_policy(&common)?;
let mut paops = ParseOps::new(policy)?;
apply_common(&common, &mut paops);
for path in &a.files {
migrate_one_file(path, &migration, &mut paops)?;
}
Ok(())
}
fn build_migration(
from: SigAlgKind,
to: SigAlgKind,
old_pub_pem: &str,
new_priv_pem: &str,
) -> Result<Migration> {
let new_pub_pem = pki::pubkey_from_priv_pem(to, new_priv_pem)?;
let new_fp = KeyFp::from_pem(&new_pub_pem)?;
let mut rng = botan::RandomNumberGenerator::new_system().map_err(Error::botan)?;
let probe = pki::sign(to, new_priv_pem, b"enprot-migrate-keys-probe", &mut rng)?;
if !pki::verify(to, &new_pub_pem, b"enprot-migrate-keys-probe", &probe)? {
return Err(Error::InvalidArg {
arg: "--new-key",
reason: "probe signature under --to failed; key does not match --to".to_string(),
});
}
Ok(Migration {
from,
to,
old_fp: KeyFp::from_pem(old_pub_pem)?,
old_pub_pem: old_pub_pem.to_string(),
new_priv_pem: new_priv_pem.to_string(),
new_signer: SignerId::new(to, new_fp),
new_pub_pem,
})
}
fn migrate_one_file(path: &str, m: &Migration, paops: &mut ParseOps) -> Result<()> {
paops.runtime.fname = path.into();
let reader = File::open(path)
.map_err(|e| Error::Io(std::io::Error::other(format!("Failed to open {path}: {e}"))))?;
let mut tree = etree::parse(BufReader::new(reader), paops)?;
let plan = plan_tree(&tree, m, paops)?;
if plan.migrate.is_empty() {
println!(
"{path}: no anchors signed by {} under the --old-key fingerprint; nothing to do",
m.from.name()
);
return Ok(());
}
let mut id_map = HashMap::new();
let migrated = rewrite_tree(&mut tree, m, &plan.migrate, &mut id_map, paops)?;
debug_assert_eq!(migrated, plan.migrate.len());
debug_assert_eq!(id_map.len(), plan.migrate.len());
let mut out = File::create(path)?;
etree::tree_write(&mut out, &tree, paops)?;
println!(
"{path}: migrated {migrated} anchor(s) {} -> {}",
m.from.name(),
m.to.name(),
);
Ok(())
}
struct Plan {
migrate: HashSet<AnchorHash>,
}
fn plan_tree(tree: &TextTree, m: &Migration, paops: &mut ParseOps) -> Result<Plan> {
let mut prefix: TextTree = Vec::new();
let policy = crate::crypto::CryptoPolicyDefault {};
for node in tree.iter() {
if let TextNode::Chain { extfields } = node {
let blob = etree::tree_to_blob(&prefix, paops)?;
let recomputed = crate::crypto::hexdigest("sha3-256", &blob, &policy)?;
let recorded = extfields.get("payload").map(|s| s.as_str()).unwrap_or("");
if recomputed != recorded {
return Err(Error::CasHashMismatch {
expected: recorded.to_string(),
actual: recomputed,
});
}
}
prefix.push(node.clone());
}
let mut anchors = Vec::new();
collect(tree, &mut anchors)?;
let mut migrate = HashSet::new();
for signed in &anchors {
if signed.anchor.signer.fp == m.old_fp {
if !signed.co_signatures.is_empty() {
return Err(Error::InvalidArg {
arg: "--from",
reason: format!(
"multi-sig anchor {} carries co-signatures; \
multi-signer migration is not supported yet",
signed.anchor.signer
),
});
}
if signed.anchor.signer.alg != m.from {
return Err(Error::InvalidArg {
arg: "--from",
reason: format!(
"anchor signed by the --old-key under {}, but --from is {}; \
pass the algorithm the anchors actually use",
signed.anchor.signer.alg.name(),
m.from.name()
),
});
}
signed.verify(&m.old_pub_pem)?;
migrate.insert(signed.id()?);
}
}
for signed in &anchors {
let id = signed.id()?;
if migrate.contains(&id) {
continue;
}
if signed.anchor.parents.iter().any(|p| migrate.contains(p)) {
return Err(Error::InvalidArg {
arg: "FILE",
reason: format!(
"anchor {} ({}) references a migrated parent but is signed by a \
different key; its signature would be invalidated",
id, signed.anchor.signer
),
});
}
}
Ok(Plan { migrate })
}
fn collect(tree: &TextTree, out: &mut Vec<SignedAnchor>) -> Result<()> {
for node in tree {
match node {
TextNode::Chain { extfields } => {
out.push(SignedAnchor::from_extfields(extfields)?);
}
TextNode::BeginEnd { txt, .. } | TextNode::Encrypted { txt, .. } => {
collect(txt, out)?;
}
_ => {}
}
}
Ok(())
}
fn rewrite_tree(
tree: &mut TextTree,
m: &Migration,
migrate: &HashSet<AnchorHash>,
id_map: &mut HashMap<AnchorHash, AnchorHash>,
paops: &mut ParseOps,
) -> Result<usize> {
let mut count = 0;
let policy = crate::crypto::CryptoPolicyDefault {};
for i in 0..tree.len() {
let migrate_this = match &tree[i] {
TextNode::Chain { extfields } => {
migrate.contains(&SignedAnchor::from_extfields(extfields)?.id()?)
}
_ => false,
};
let payload_hash = if migrate_this {
let blob = etree::tree_to_blob(tree[..i].to_vec().as_ref(), paops)?;
let payload_hex = crate::crypto::hexdigest("sha3-256", &blob, &policy)?;
let mut payload_arr = [0u8; 32];
payload_arr.copy_from_slice(&hex::decode(payload_hex)?);
Some(crate::ledger::PayloadHash(payload_arr))
} else {
None
};
match &mut tree[i] {
TextNode::Chain { extfields } => {
let signed = SignedAnchor::from_extfields(extfields)?;
let old_id = signed.id()?;
if !migrate.contains(&old_id) {
continue;
}
let payload_hash = payload_hash.expect("computed above for migratable anchors");
let parents = signed
.anchor
.parents
.iter()
.map(|p| id_map.get(p).copied().unwrap_or(*p))
.collect();
let mut builder =
crate::ledger::Anchor::builder(m.new_signer.clone(), payload_hash)
.with_parents(parents)
.with_mutations(signed.anchor.mutations.clone());
if let Some(ref ts) = signed.anchor.timestamp {
builder = builder.with_timestamp(ts.clone());
}
let new_signed = builder
.build()
.sign(&m.new_priv_pem, &m.new_pub_pem, m.to)?;
*extfields = new_signed.to_extfields();
id_map.insert(old_id, new_signed.id()?);
count += 1;
}
TextNode::BeginEnd { txt, .. } | TextNode::Encrypted { txt, .. } => {
count += rewrite_nested(txt, m, migrate, id_map)?;
}
_ => {}
}
}
Ok(count)
}
fn rewrite_nested(
tree: &mut TextTree,
m: &Migration,
migrate: &HashSet<AnchorHash>,
id_map: &mut HashMap<AnchorHash, AnchorHash>,
) -> Result<usize> {
let mut count = 0;
for node in tree.iter_mut() {
match node {
TextNode::Chain { extfields } => {
let signed = SignedAnchor::from_extfields(extfields)?;
let old_id = signed.id()?;
if !migrate.contains(&old_id) {
continue;
}
let parents = signed
.anchor
.parents
.iter()
.map(|p| id_map.get(p).copied().unwrap_or(*p))
.collect();
let mut builder = crate::ledger::Anchor::builder(
m.new_signer.clone(),
signed.anchor.payload_hash,
)
.with_parents(parents)
.with_mutations(signed.anchor.mutations.clone());
if let Some(ref ts) = signed.anchor.timestamp {
builder = builder.with_timestamp(ts.clone());
}
let new_signed = builder
.build()
.sign(&m.new_priv_pem, &m.new_pub_pem, m.to)?;
*extfields = new_signed.to_extfields();
id_map.insert(old_id, new_signed.id()?);
count += 1;
}
TextNode::BeginEnd { txt, .. } | TextNode::Encrypted { txt, .. } => {
count += rewrite_nested(txt, m, migrate, id_map)?;
}
_ => {}
}
}
Ok(count)
}