use std::sync::Arc;
use anyhow::Context as _;
use knot_types::{KnotHostname, KnotServiceUrl, OwnerDid, RefName, RepoDid, RepoRkey};
use serde::{Deserialize, Serialize};
use crate::config::ValidatedConfig;
const PENDING_REF: &str = "refs/atproto/plc/pending";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingGenesis {
pub did: RepoDid,
pub operation: serde_json::Value,
#[serde(default)]
pub owner: Option<OwnerDid>,
#[serde(default)]
pub rkey: Option<RepoRkey>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub seed: u64,
#[serde(default)]
pub created_at: i64,
}
pub fn format_rfc3339(secs: i64) -> String {
chrono::DateTime::from_timestamp(secs, 0)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_default()
}
pub fn load_pending_genesis(repo: &knot_git::Repo) -> anyhow::Result<Vec<PendingGenesis>> {
let name = RefName::new(PENDING_REF)?;
let Some(oid) = repo.find_ref(&name)? else {
return Ok(Vec::new());
};
let object = repo.git().find_object(oid.object_id())?;
Ok(serde_json::from_slice(&object.detach().data)?)
}
fn save_pending_genesis(repo: &knot_git::Repo, entries: &[PendingGenesis]) -> anyhow::Result<()> {
let name = RefName::new(PENDING_REF)?;
let old = repo.find_ref(&name)?;
if entries.is_empty() {
if let Some(old) = old {
repo.update_ref(&knot_git::RefUpdate::Delete { name, old })?;
}
return Ok(());
}
let bytes = serde_json::to_vec(entries)?;
let blob = repo.git().write_blob(&bytes)?;
let new = knot_types::Oid::from(blob.detach());
let update = match old {
Some(old) => knot_git::RefUpdate::Update { name, old, new },
None => knot_git::RefUpdate::Create { name, new },
};
repo.update_ref(&update)?;
Ok(())
}
pub fn enqueue_pending_genesis(repo: &knot_git::Repo, entry: PendingGenesis) -> anyhow::Result<()> {
let mut entries = load_pending_genesis(repo)?;
entries.retain(|existing| existing.did != entry.did);
entries.push(entry);
save_pending_genesis(repo, &entries)
}
pub fn drop_pending_genesis(repo: &knot_git::Repo, did: &RepoDid) -> anyhow::Result<bool> {
let mut entries = load_pending_genesis(repo)?;
let before = entries.len();
entries.retain(|existing| existing.did != *did);
let removed = entries.len() != before;
if removed {
save_pending_genesis(repo, &entries)?;
}
Ok(removed)
}
pub async fn verify_did_document(
transport: &knot_runtime::ReqwestHttp,
plc_directory: &url::Url,
did: &RepoDid,
hostname: &KnotHostname,
) -> anyhow::Result<()> {
let base = plc_directory.as_str().trim_end_matches('/');
let url = url::Url::parse(&format!("{base}/{}", did.as_str()))
.context("construct plc document url")?;
if url.scheme() != "https" {
anyhow::bail!("insecure scheme in plc directory url: {url}");
}
let blocked_ip = match url.host() {
Some(url::Host::Ipv4(ip)) => knot_runtime::is_blocked_ip(ip.into()).then(|| ip.to_string()),
Some(url::Host::Ipv6(ip)) => knot_runtime::is_blocked_ip(ip.into()).then(|| ip.to_string()),
_ => None,
};
if let Some(host) = blocked_ip {
anyhow::bail!("blocked host in plc directory url: {host}");
}
let request = knot_runtime::HttpRequest::get(url);
let response = knot_runtime::HttpTransport::execute(transport, request).await?;
if !response.status.is_success() {
anyhow::bail!("failed to fetch DID document: status {}", response.status);
}
let doc: serde_json::Value =
serde_json::from_slice(response.body.as_ref()).context("parse DID document JSON")?;
let services = doc
.get("service")
.and_then(|s| s.as_array())
.ok_or_else(|| anyhow::anyhow!("DID document has no service list"))?;
let found = services.iter().any(|service| {
service.get("type").and_then(|t| t.as_str()) == Some("TangledKnot")
&& service
.get("serviceEndpoint")
.and_then(|e| e.as_str())
.and_then(|endpoint| url::Url::parse(endpoint).ok())
.is_some_and(|endpoint_url| {
endpoint_url.scheme() == "https"
&& endpoint_url.host_str() == Some(hostname.as_str())
})
});
anyhow::ensure!(
found,
"DID document for {did} does not declare a TangledKnot service pointing to {hostname}"
);
Ok(())
}
fn index(config: &ValidatedConfig) -> anyhow::Result<knot_index::Index> {
let layout = config.layout()?;
let index = knot_index::Index::new(&layout.meta_path(&config.knot_did)?, layout);
index.rebuild().context("rebuild index")?;
Ok(index)
}
pub(crate) fn load_state(
meta_repo: &knot_git::Repo,
) -> anyhow::Result<Option<knot_cobs::Registry>> {
let cob_store = knot_cob::CobStore::new(meta_repo);
cob_store
.list::<knot_cobs::RepoRegistryCob>()?
.first()
.map(|&object| cob_store.get::<knot_cobs::RepoRegistryCob>(object))
.transpose()
.map(|cob| cob.map(|cob| cob.into_state()))
.map_err(Into::into)
}
pub(crate) fn matching_bare_name(
index: &knot_index::Index,
name: &str,
) -> anyhow::Result<Vec<RepoDid>> {
let Some(clone) = knot_types::ClonePath::parse(name) else {
return Ok(Vec::new());
};
let mut owners = std::collections::BTreeSet::new();
for did in index.hosted_repos() {
match index.ownership_of(&did) {
knot_index::Resolved::Ready(Some(ownership)) => {
owners.insert(ownership.owner);
}
knot_index::Resolved::Ready(None) => {}
knot_index::Resolved::Warming => {
anyhow::bail!("the registry is still warming, try again");
}
}
}
let mut matched = std::collections::BTreeSet::new();
for owner in &owners {
match index.resolve_clone_path(owner, &clone) {
knot_index::Resolved::Ready(Some(did)) => {
matched.insert(did);
}
knot_index::Resolved::Ready(None) => {}
knot_index::Resolved::Warming => {
anyhow::bail!("the registry is still warming, try again");
}
}
}
Ok(matched.into_iter().collect())
}
pub(crate) fn hosted(config: &ValidatedConfig) -> anyhow::Result<Vec<knot_types::RepoDid>> {
Ok(index(config)?.hosted_repos())
}
pub async fn list(config: Arc<ValidatedConfig>) -> anyhow::Result<()> {
let layout = config.layout()?;
let meta_path = layout.meta_path(&config.knot_did)?;
let index = index(&config)?;
let meta_repo = layout
.bootstrap_meta(&config.knot_did)
.context("open meta.git")?;
let cob_objects = knot_cob::CobStore::new(&meta_repo).list::<knot_cobs::RepoRegistryCob>()?;
let registry_state = load_state(&meta_repo)?;
let hosted = index.hosted_repos();
if hosted.is_empty() {
eprintln!(
"no hosted repos: read the registry cob of {} ({} registry object(s), 0 index entries)",
meta_path.display(),
cob_objects.len()
);
}
for did in hosted {
let knot_index::Resolved::Ready(Some(ownership)) = index.ownership_of(&did) else {
continue;
};
let name = registry_state
.as_ref()
.and_then(|reg| reg.record_of(&did))
.map(|rec| rec.name.to_string())
.unwrap_or_else(|| ownership.rkey.to_string());
let head_str = layout
.open(&did)
.ok()
.and_then(|repo| repo.head())
.map(|head| head.target.to_string())
.unwrap_or_else(|| "-".to_string());
let source_path = layout.guarded_path(&did)?;
println!(
"{did} {} {} {name} {head_str} {}",
ownership.owner,
ownership.rkey,
source_path.display()
);
}
Ok(())
}
#[derive(Default)]
pub(crate) struct RotationSource<'a> {
pub rotation_key_env: Option<&'a str>,
pub from_sealed_store: Option<&'a std::path::Path>,
pub from_master_key_env: &'a str,
pub from_knot_did: Option<&'a str>,
}
pub(crate) fn rotation_matches(
last_op: &serde_json::Value,
signer: &knot_runtime::K256Signer,
) -> bool {
use knot_runtime::Signer as _;
let did_key = format!(
"did:key:{}",
knot_types::ActorId::from_secp256k1(signer.public_key().as_bytes())
);
last_op
.get("rotationKeys")
.and_then(serde_json::Value::as_array)
.is_some_and(|keys| keys.iter().any(|k| k.as_str() == Some(did_key.as_str())))
}
pub(crate) fn parse_rotation_scalar(raw: &str) -> anyhow::Result<knot_runtime::K256Signer> {
use base64::Engine as _;
let trimmed = raw.trim();
let bytes = knot_types::decode_hex(trimmed.as_bytes())
.or_else(|| {
base64::engine::general_purpose::STANDARD
.decode(trimmed)
.ok()
})
.or_else(|| {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(trimmed)
.ok()
})
.context("rotation key must be 32 bytes in hex or base64")?;
knot_runtime::K256Signer::from_slice(&bytes).context("parse secp256k1 rotation key")
}
pub(crate) fn open_foreign_store(
path: &std::path::Path,
master_key_env: &str,
) -> anyhow::Result<knot_secrets::SealedStore> {
use base64::Engine as _;
anyhow::ensure!(
path.is_file(),
"sealed key store {} does not exist",
path.display()
);
let sibling_env = path.parent().map(|p| p.join("master.env"));
let mut candidates: Vec<&std::path::Path> = Vec::new();
if let Some(ref p) = sibling_env {
candidates.push(p.as_path());
}
candidates.push(std::path::Path::new("/run/agenix/knotMasterKey"));
let raw = crate::identity::read_env_or_files(master_key_env, &candidates)?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(raw.trim())
.context("decode master key as base64")?;
let master_key = knot_secrets::MasterKey::new(bytes).context("master key from environment")?;
knot_secrets::SealedStore::open(path, &master_key, Box::new(knot_runtime::OsEntropy))
.with_context(|| format!("open sealed key store {}", path.display()))
}
pub(crate) fn find_rotation_signer(
last_op: &serde_json::Value,
repo_did: &RepoDid,
local_secrets: &knot_secrets::SealedStore,
local_knot_did: &knot_types::KnotId,
source: &RotationSource<'_>,
) -> anyhow::Result<knot_runtime::K256Signer> {
if let Some(env_name) = source.rotation_key_env {
let raw = std::env::var(env_name)
.with_context(|| format!("read rotation key from environment variable {env_name}"))?;
let signer = parse_rotation_scalar(&raw)?;
anyhow::ensure!(
rotation_matches(last_op, &signer),
"rotation key in {env_name} does not match rotationKeys {:?} on {repo_did}",
last_op.get("rotationKeys")
);
return Ok(signer);
}
if let Some(store_path) = source.from_sealed_store {
let foreign = open_foreign_store(store_path, source.from_master_key_env)?;
if let Some(raw_knot) = source.from_knot_did {
let foreign_knot =
knot_types::KnotId::new(raw_knot).context("parse --from-knot-did")?;
let signer = foreign.signer(&foreign_knot).with_context(|| {
format!(
"key for {foreign_knot} not found in {}",
store_path.display()
)
})?;
if rotation_matches(last_op, &signer) {
return Ok(signer);
}
}
if let Ok(per_repo_id) = knot_types::KnotId::new(repo_did.as_str())
&& let Ok(signer) = foreign.signer(knot_secrets::SealedKeyId::from(&per_repo_id))
&& rotation_matches(last_op, &signer)
{
return Ok(signer);
}
anyhow::bail!(
"no key in {} matches rotationKeys {:?} for {repo_did}",
store_path.display(),
last_op.get("rotationKeys")
);
}
if let Ok(signer) = local_secrets.signer(local_knot_did)
&& rotation_matches(last_op, &signer)
{
return Ok(signer);
}
if let Ok(per_repo_id) = knot_types::KnotId::new(repo_did.as_str())
&& let Ok(signer) = local_secrets.signer(knot_secrets::SealedKeyId::from(&per_repo_id))
&& rotation_matches(last_op, &signer)
{
return Ok(signer);
}
anyhow::bail!(
"no available signing key matches rotationKeys {:?} for {repo_did}; supply --rotation-key-env or --from-sealed-store",
last_op.get("rotationKeys")
)
}
fn decode_secp256k1_multikey(raw: &str) -> anyhow::Result<knot_runtime::PublicKeyBytes> {
let multibase = raw.trim().strip_prefix("did:key:").unwrap_or(raw.trim());
let decoded = knot_types::crypto::PublicKey::decode_owned(multibase)
.map_err(|e| anyhow::anyhow!("decode multikey {raw:?}: {e}"))?;
anyhow::ensure!(
matches!(decoded.codec, knot_types::crypto::KeyCodec::Secp256k1),
"key {raw:?} is not secp256k1"
);
Ok(knot_runtime::PublicKeyBytes::from_bytes(
decoded.bytes.into_owned(),
))
}
async fn fetch_knot_public_key(
transport: &knot_runtime::ReqwestHttp,
target_url: &KnotServiceUrl,
) -> anyhow::Result<knot_runtime::PublicKeyBytes> {
let base = target_url.as_str().trim_end_matches('/');
let url = url::Url::parse(&format!("{base}/.well-known/did.json"))
.context("construct target knot did.json url")?;
anyhow::ensure!(
url.scheme() == "https",
"refusing to fetch target knot did.json over non-https url: {url}"
);
let response =
knot_runtime::HttpTransport::execute(transport, knot_runtime::HttpRequest::get(url))
.await
.context("fetch target knot did.json")?;
anyhow::ensure!(
response.status.is_success(),
"fetching target knot did.json returned HTTP {}",
response.status
);
let doc: knot_types::did_doc::DidDocument =
serde_json::from_slice(&response.body).context("parse target knot did.json")?;
let methods = doc
.verification_method
.as_ref()
.context("target knot did.json has no verificationMethod")?;
let multibase = methods
.iter()
.find(|m| m.id.as_str().ends_with("#atproto"))
.and_then(|m| m.public_key_multibase.as_ref())
.context("target knot did.json has no #atproto publicKeyMultibase")?;
decode_secp256k1_multikey(multibase.as_str())
}
pub async fn plc_handover(
config: Arc<ValidatedConfig>,
args: crate::cli::HandoverArgs,
) -> anyhow::Result<()> {
let repo_did = crate::owner::resolve(&config, &args.repo)?;
let target_url = KnotServiceUrl::new(args.to_url.trim_end_matches('/'))
.context("parse --to-url as a knot service url")?;
let parsed_url = url::Url::parse(target_url.as_str()).context("parse target url")?;
let host_str = parsed_url
.host_str()
.context("--to-url has no host")?
.to_string();
let target_hostname =
KnotHostname::new(host_str).context("parse target hostname from --to-url")?;
let layout = config.layout()?;
let meta_repo = layout
.bootstrap_meta(&config.knot_did)
.context("open meta.git")?;
let secrets = crate::identity::open_store(&config)?;
secrets
.ensure(&config.knot_did)
.context("ensure knot signing key")?;
let transport =
knot_runtime::ReqwestHttp::new(config.http_limits()).context("init http transport")?;
let atproto = config
.atproto(transport.clone())
.context("init plc directory")?;
let target_public = match args.to_key.as_deref() {
Some(raw) => decode_secp256k1_multikey(raw)?,
None => fetch_knot_public_key(&transport, &target_url).await?,
};
let target = knot_atproto::RepoTarget::new(&target_public, &target_url);
let last_op = atproto
.last_plc_operation(&repo_did)
.await
.context("fetch last plc operation")?
.with_context(|| format!("no plc operation found on directory for {repo_did}"))?;
if !target
.satisfied_by(&last_op)
.context("check current plc operation")?
{
let signer = find_rotation_signer(
&last_op,
&repo_did,
&secrets,
&config.knot_did,
&RotationSource {
from_master_key_env: &config.master_key_env,
..RotationSource::default()
},
)?;
let update = knot_atproto::update_operation(&signer, &last_op, &target)
.context("sign plc handover operation")?;
atproto
.submit_plc_operation(&repo_did, &update)
.await
.context("submit plc handover operation")?;
}
verify_did_document(
&transport,
&config.plc_directory,
&repo_did,
&target_hostname,
)
.await
.context("verify live repo DID document on PLC after handover")?;
let _ = drop_pending_genesis(&meta_repo, &repo_did)?;
println!("{repo_did} -> {target_url}");
Ok(())
}
pub async fn plc_sync(config: Arc<ValidatedConfig>) -> anyhow::Result<()> {
use knot_runtime::Signer as _;
let layout = config.layout()?;
let meta_path = layout.meta_path(&config.knot_did)?;
let meta_repo = layout
.bootstrap_meta(&config.knot_did)
.context("open meta.git")?;
let entries = load_pending_genesis(&meta_repo)?;
if entries.is_empty() {
return Ok(());
}
let secrets = crate::identity::open_store(&config)?;
secrets
.ensure(&config.knot_did)
.context("ensure knot signing key")?;
let knot_signer = secrets
.signer(&config.knot_did)
.context("get knot signer")?;
let service_url =
KnotServiceUrl::new(format!("https://{}", config.hostname)).context("knot service url")?;
let target = knot_atproto::RepoTarget::new(&knot_signer.public_key(), &service_url);
let transport =
knot_runtime::ReqwestHttp::new(config.http_limits()).context("init http transport")?;
let atproto = config
.atproto(transport.clone())
.context("init plc directory")?;
let mut failed = 0;
let mut verified = Vec::new();
for entry in &entries {
let already_satisfied = match atproto.last_plc_operation(&entry.did).await {
Ok(Some(last_op)) => target.satisfied_by(&last_op).unwrap_or(false),
_ => false,
};
let submit_res = if already_satisfied {
Ok(())
} else {
let operation = if !entry.operation.is_null() {
entry.operation.clone()
} else {
let (Some(owner), Some(rkey)) = (&entry.owner, &entry.rkey) else {
failed += 1;
eprintln!(
"pending genesis for {}: missing owner/rkey to reconstruct operation",
entry.did
);
continue;
};
let entropy = knot_runtime::SeededEntropy::new(entry.seed);
let nonce = knot_atproto::MintNonce::mint(&entropy, owner, rkey);
match knot_atproto::prepare_repo_did(&knot_signer, &service_url, &nonce) {
Ok(prep) => prep.operation().clone(),
Err(e) => {
failed += 1;
eprintln!(
"pending genesis for {}: prepare_repo_did failed: {e:#}",
entry.did
);
continue;
}
}
};
atproto.submit_plc_operation(&entry.did, &operation).await
};
let verify_res = verify_did_document(
&transport,
&config.plc_directory,
&entry.did,
&config.hostname,
)
.await;
match verify_res {
Ok(()) => {
verified.push(entry.did.clone());
}
Err(verify_err) => {
failed += 1;
match submit_res {
Err(submit_err) => eprintln!(
"pending genesis for {}: submission failed: {submit_err:#}; verification failed: {verify_err:#}",
entry.did
),
Ok(_) => eprintln!(
"pending genesis for {}: verification failed: {verify_err:#}",
entry.did
),
}
}
}
}
for did in &verified {
drop_pending_genesis(&meta_repo, did)?;
}
if !verified.is_empty() {
let index = knot_index::Index::new(&meta_path, layout);
let _ = index.rebuild();
}
anyhow::ensure!(
failed == 0,
"{failed} pending genesis operation(s) failed to sync"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renamed_repo_carries_its_new_name_in_snapshot_and_resolves_its_prior_rkey_alias() {
let root = tempfile::tempdir().unwrap();
let layout = knot_git::Layout::new(root.path());
let knot_did = knot_types::KnotId::new("did:web:knot.test").unwrap();
let meta = layout.bootstrap_meta(&knot_did).unwrap();
let repo_did = RepoDid::new("did:plc:renamedrepo1234567890123").unwrap();
let owner = OwnerDid::new("did:plc:owner1234567890123456789").unwrap();
let _repo = layout.create(&repo_did).unwrap();
let signer = knot_runtime::K256Signer::generate(&knot_runtime::SeededEntropy::new(7));
let store = knot_cob::CobStore::new(&meta);
let home = knot_cob::CobHome::from(&knot_did);
let now = knot_types::UnixSeconds::new(1_700_000_000);
let object = store
.create(
&home,
&knot_cobs::RegistryChange::Register(knot_cobs::Registration {
owner: owner.clone(),
rkey: RepoRkey::new("old-name").unwrap(),
name: knot_types::RepoName::new("Old-Name").unwrap(),
repo: repo_did.clone(),
created_at: now,
policy: knot_types::ContributionPolicy::default(),
fostered: false,
retired_at: None,
}),
&signer,
now,
)
.unwrap()
.object;
knot_cobs::rename_repo(
&store,
&home,
object,
knot_cobs::Rename {
owner: owner.clone(),
rkey: RepoRkey::new("new-name").unwrap(),
name: knot_types::RepoName::new("New-Name").unwrap(),
repo: repo_did.clone(),
},
&signer,
now,
)
.unwrap();
let index = knot_index::Index::new(layout.meta_path(&knot_did).unwrap(), layout.clone());
index.rebuild().unwrap();
let state = load_state(&meta).unwrap();
let snap =
crate::snapshot::snapshot_repo(&layout, &index, state.as_ref(), &repo_did).unwrap();
assert_eq!(snap.rkey.as_str(), "new-name");
assert_eq!(snap.name.as_ref().map(|n| n.as_str()), Some("New-Name"));
assert_eq!(
matching_bare_name(&index, "new-name").unwrap(),
vec![repo_did.clone()]
);
assert_eq!(
matching_bare_name(&index, "New-Name").unwrap(),
vec![repo_did.clone()]
);
assert_eq!(
matching_bare_name(&index, "old-name").unwrap(),
vec![repo_did],
"prior rkey stays resolvable as a bare-name alias"
);
assert!(
matching_bare_name(&index, "Old-Name").unwrap().is_empty(),
"prior display name is unbound after rename"
);
}
#[test]
fn format_rfc3339_matches_utc() {
assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
assert_eq!(format_rfc3339(1609459200), "2021-01-01T00:00:00Z");
assert_eq!(format_rfc3339(1700000000), "2023-11-14T22:13:20Z");
}
#[tokio::test]
async fn live_plc_repo_did_has_tangled_knot_service_on_host() {
if std::env::var("KNOT_STATIC_LIVE_PLC").unwrap_or_default() != "1" {
return;
}
let directory = url::Url::parse("https://plc.directory").unwrap();
let transport = knot_runtime::ReqwestHttp::new(knot_runtime::HttpLimits::default())
.expect("http transport");
let did = RepoDid::new("did:plc:yovatos6i5a4fonfcxusiqqw".to_string()).unwrap();
let hostname = knot_types::KnotHostname::new("knot1.tangled.sh".to_string()).unwrap();
verify_did_document(&transport, &directory, &did, &hostname)
.await
.expect("live plc resolution must carry a TangledKnot service on the host");
}
#[test]
fn rotation_signer_resolves_from_sealed_store_and_updates_plc_target() {
use knot_runtime::Signer as _;
let dir = tempfile::tempdir().unwrap();
let master = knot_secrets::MasterKey::new([9u8; 32]).unwrap();
let store_path = dir.path().join("old-keys.sealed");
let store = knot_secrets::SealedStore::open(
&store_path,
&master,
Box::new(knot_runtime::SeededEntropy::new(3)),
)
.unwrap();
let old_knot = knot_types::KnotId::new("did:web:old.knot.test").unwrap();
store.ensure(&old_knot).unwrap();
let old_signer = store.signer(&old_knot).unwrap();
let old_url = KnotServiceUrl::new("https://old.knot.test").unwrap();
let nonce = knot_atproto::MintNonce::mint(
&knot_runtime::SeededEntropy::new(11),
&OwnerDid::new("did:plc:owner1234567890123456789").unwrap(),
&RepoRkey::new("migrated").unwrap(),
);
let prepared = knot_atproto::prepare_repo_did(&old_signer, &old_url, &nonce).unwrap();
let last_op = prepared.operation();
let new_signer = knot_runtime::K256Signer::generate(&knot_runtime::SeededEntropy::new(22));
let new_url = KnotServiceUrl::new("https://new.knot.test").unwrap();
let new_key_multibase =
knot_types::ActorId::from_secp256k1(new_signer.public_key().as_bytes()).to_string();
let decoded_pub =
decode_secp256k1_multikey(&format!("did:key:{new_key_multibase}")).unwrap();
assert_eq!(decoded_pub, new_signer.public_key());
let target = knot_atproto::RepoTarget::new(&decoded_pub, &new_url);
assert!(!target.satisfied_by(last_op).unwrap());
assert!(rotation_matches(last_op, &old_signer));
assert!(!rotation_matches(last_op, &new_signer));
let updated = knot_atproto::update_operation(&old_signer, last_op, &target).unwrap();
assert!(target.satisfied_by(&updated).unwrap());
assert!(rotation_matches(&updated, &new_signer));
}
}