use super::*;
use base64::Engine as _;
use knot_types::AccountDid;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Mutex;
use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _};
struct PdsStub {
url: String,
records: Arc<Mutex<BTreeMap<String, (String, Value)>>>,
events: Arc<Mutex<Vec<String>>>,
}
impl PdsStub {
async fn start(initial_rkey: &str, repo_did: &str) -> Self {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let records = Arc::new(Mutex::new(BTreeMap::from([(
initial_rkey.to_string(),
(
"cid-initial".to_string(),
json!({
"$type": "org.tangled.repo.declaration",
"repo": repo_did,
"createdAt": "2023-11-14T22:13:20Z",
}),
),
)])));
let events = Arc::new(Mutex::new(Vec::new()));
let (recs, evs) = (records.clone(), events.clone());
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let (recs, evs) = (recs.clone(), evs.clone());
tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(&mut stream);
let mut first_line = String::new();
if reader.read_line(&mut first_line).await.unwrap_or(0) == 0 {
return;
}
let first_line = first_line.trim_end().to_string();
let mut content_length = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).await.unwrap_or(0) == 0 || line == "\r\n" {
break;
}
if let Some(len) = line.to_ascii_lowercase().strip_prefix("content-length:")
{
content_length = len.trim().parse().unwrap_or(0);
}
}
let mut body = vec![0u8; content_length];
if content_length > 0 {
let _ = reader.read_exact(&mut body).await;
}
let body_str = String::from_utf8_lossy(&body);
let (status, answer) = if first_line
.starts_with("GET /xrpc/com.atproto.repo.getRecord?")
{
let query = first_line
.split_whitespace()
.nth(1)
.and_then(|u| u.split_once('?'))
.map(|(_, q)| q)
.unwrap_or("");
let rkey = url::form_urlencoded::parse(query.as_bytes())
.find(|(k, _)| k == "rkey")
.map(|(_, v)| v.into_owned())
.unwrap_or_default();
evs.lock().unwrap().push(format!("get:{rkey}"));
match recs.lock().unwrap().get(&rkey).cloned() {
Some((cid, value)) => (
"200 OK",
json!({
"uri": format!("at://did:plc:owner/org.tangled.repo.declaration/{rkey}"),
"cid": cid,
"value": value,
})
.to_string(),
),
None => (
"400 Bad Request",
json!({ "error": "RecordNotFound", "message": "not found" })
.to_string(),
),
}
} else if first_line.starts_with("POST /xrpc/com.atproto.repo.createRecord ")
|| first_line.starts_with("POST /xrpc/com.atproto.repo.putRecord ")
{
let op = if first_line.contains("createRecord") {
"create"
} else {
"put"
};
let parsed: Value = serde_json::from_str(&body_str).unwrap();
let rkey = parsed["rkey"].as_str().unwrap().to_string();
let cid = format!("cid-{rkey}");
evs.lock().unwrap().push(format!("{op}:{rkey}"));
recs.lock()
.unwrap()
.insert(rkey.clone(), (cid.clone(), parsed["record"].clone()));
(
"200 OK",
json!({
"uri": format!("at://did:plc:owner/org.tangled.repo.declaration/{rkey}"),
"cid": cid,
})
.to_string(),
)
} else if first_line.starts_with("POST /xrpc/com.atproto.repo.deleteRecord ") {
let parsed: Value = serde_json::from_str(&body_str).unwrap();
let rkey = parsed["rkey"].as_str().unwrap().to_string();
let swap = parsed["swapRecord"].as_str().unwrap_or("");
evs.lock().unwrap().push(format!("delete:{rkey}:{swap}"));
recs.lock().unwrap().remove(&rkey);
("200 OK", "{}".to_string())
} else {
("404 Not Found", "{}".to_string())
};
let reply = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}",
answer.len()
);
let _ = stream.write_all(reply.as_bytes()).await;
});
}
});
Self {
url,
records,
events,
}
}
}
#[tokio::test]
async fn repo_rename_moves_the_pds_declaration_around_the_manifest_write_and_post_activate_keeps_it()
{
const KEY_ENV: &str = "KNOT_STATIC_OWNER_RENAME_TEST_KEY";
// SAFETY: only this test reads this variable name.
unsafe {
std::env::set_var(
KEY_ENV,
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [9u8; 32]),
);
}
let root = tempfile::tempdir().unwrap();
let scan = root.path().join("state");
let output = root.path().join("output");
std::fs::create_dir_all(&scan).unwrap();
std::fs::create_dir_all(&output).unwrap();
let owner = knot_types::AccountDid::new("did:plc:owner1234567890123456789").unwrap();
let owner_did = knot_types::OwnerDid::from(owner.clone());
let repo_did = RepoDid::new("did:plc:renamerepo12345678901234").unwrap();
let pds = PdsStub::start("knot-static", repo_did.as_str()).await;
let config_path = root.path().join("config.toml");
crate::cas::atomic_write(
&config_path,
format!(
r#"[knot]
hostname = "127.0.0.1"
owner = "{owner}"
[repo]
scan_path = "{}"
default_branch = "main"
object_format = "sha1"
[secrets]
sealed_key_file = "{}"
master_key_env = "{KEY_ENV}"
[atproto]
plc_directory = "https://127.0.0.1:9"
allow_private_addresses = true
[output]
root = "{}"
[activate]
nginx_binary = "true"
listen = "127.0.0.1:9"
[crawl]
urls = []
"#,
scan.display(),
root.path().join("keys.sealed").display(),
output.display(),
)
.as_bytes(),
)
.unwrap();
let config = Arc::new(ValidatedConfig::load(&config_path).unwrap());
crate::init::run(config.clone()).await.unwrap();
let layout = config.layout().unwrap();
let meta = layout.bootstrap_meta(&config.knot_did).unwrap();
let repo = layout.create(&repo_did).unwrap();
let work = root.path().join("work");
std::fs::create_dir_all(&work).unwrap();
let run_git = |dir: &std::path::Path, args: &[&str]| {
let out = crate::test_git()
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
};
run_git(&work, &["init", "-q", "-b", "main"]);
crate::cas::atomic_write(&work.join("README.md"), b"# knot-static\n").unwrap();
run_git(&work, &["add", "README.md"]);
run_git(&work, &["commit", "-q", "-m", "init"]);
run_git(
&work,
&[
"push",
"-q",
repo.path().to_str().unwrap(),
"HEAD:refs/heads/main",
],
);
crate::commit_graph::rewrite(repo.path());
let signer = crate::identity::load_knot_signer(&config).unwrap();
let now = knot_types::UnixSeconds::new(1_700_000_000);
knot_cob::CobStore::new(&meta)
.create(
&knot_cob::CobHome::from(&config.knot_did),
&knot_cobs::RegistryChange::Register(knot_cobs::Registration {
owner: owner_did.clone(),
rkey: knot_types::RepoRkey::new("knot-static").unwrap(),
name: knot_types::RepoName::new("knot-static").unwrap(),
repo: repo_did.clone(),
created_at: now,
policy: ContributionPolicy::default(),
fostered: false,
retired_at: None,
}),
&signer,
now,
)
.unwrap();
crate::oauth::seed_test_session(&config, &owner, &pds.url).unwrap();
let repo_path = repo.path().to_path_buf();
let aside_path = root.path().join("repo-aside");
std::fs::rename(&repo_path, &aside_path).unwrap();
let failed = rename(
config.clone(),
RenameArgs {
repo: "knot-static".to_string(),
new_name: "Yumak".to_string(),
},
)
.await;
assert!(failed.is_err());
assert_eq!(
*pds.events.lock().unwrap(),
vec![
"get:yumak",
"get:knot-static",
"create:yumak",
"delete:yumak:cid-yumak",
],
"failed knot write rolls back the newly created declaration on the PDS"
);
assert!(pds.records.lock().unwrap().contains_key("knot-static"));
assert!(!pds.records.lock().unwrap().contains_key("yumak"));
std::fs::rename(&aside_path, &repo_path).unwrap();
pds.events.lock().unwrap().clear();
rename(
config.clone(),
RenameArgs {
repo: "knot-static".to_string(),
new_name: "Yumak".to_string(),
},
)
.await
.unwrap();
assert_eq!(
*pds.events.lock().unwrap(),
vec![
"get:yumak",
"get:knot-static",
"create:yumak",
"delete:knot-static:cid-initial",
]
);
assert!(
!pds.records.lock().unwrap().contains_key("knot-static"),
"old declaration was deleted from the owner's PDS"
);
assert!(
pds.records.lock().unwrap().contains_key("yumak"),
"new declaration is on the owner's PDS"
);
pds.events.lock().unwrap().clear();
crate::pds::publish_all(config.clone(), &[repo_did.to_string()]).await;
crate::pds::publish_owned_by(config.clone(), &owner).await;
assert_eq!(
*pds.events.lock().unwrap(),
vec!["put:yumak", "put:yumak"],
"post-activate and publish_owned_by only publish the new rkey and never recreate the old one"
);
assert!(!pds.records.lock().unwrap().contains_key("knot-static"));
assert_eq!(resolve(&config, "yumak").unwrap(), repo_did);
assert_eq!(resolve(&config, "Yumak").unwrap(), repo_did);
assert_eq!(resolve(&config, "knot-static").unwrap(), repo_did);
}
fn keys_config(root: &std::path::Path) -> ValidatedConfig {
let raw = format!(
"[knot]\nhostname = \"knot.test\"\nowner = \"did:plc:owner\"\n[repo]\nscan_path = \"{root}\"\n[secrets]\nsealed_key_file = \"{root}/keys.sealed\"\n[output]\nroot = \"{root}\"\n[atproto]\nplc_directory = \"https://127.0.0.1:9\"\n",
root = root.display()
);
ValidatedConfig::from_toml(&raw).unwrap()
}
fn ssh_string(bytes: &[u8]) -> Vec<u8> {
[&(bytes.len() as u32).to_be_bytes()[..], bytes].concat()
}
fn blob_line(algorithm: &[u8], material: &[u8]) -> String {
let blob = [ssh_string(algorithm), ssh_string(material)].concat();
format!(
"{} {}",
String::from_utf8_lossy(algorithm),
base64::engine::general_purpose::STANDARD.encode(blob)
)
}
fn store_keys(config: &ValidatedConfig, keys: &[(&AccountDid, Vec<String>)]) {
let store = crate::datastore::Datastore::at(&config.output_root);
let record = store.record::<crate::datastore::schema::SshKeys>(&());
let mut held = record.hold().unwrap();
held.value().clear();
for (account, lines) in keys {
held.value().insert((*account).clone(), lines.clone());
}
held.commit().unwrap();
}
/// sshd's view of one line: options, then a key type, a blob and nothing else.
fn assert_key_text(key: &str, context: &str) {
let (key_type, blob) = key
.split_once(' ')
.unwrap_or_else(|| panic!("{context}: no blob in {key:?}"));
assert!(
["ssh-", "ecdsa-", "sk-"]
.iter()
.any(|p| key_type.starts_with(p))
&& key_type
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-@.".contains(c)),
"{context}: key type {key_type:?}"
);
assert!(
!blob.is_empty()
&& blob
.chars()
.all(|c| c.is_ascii_alphanumeric() || "+/=".contains(c))
&& base64::engine::general_purpose::STANDARD
.decode(blob)
.is_ok(),
"{context}: blob {blob:?}"
);
}
/// every line is forced to `command` and restricted; returns how many there are.
fn assert_forced(listed: &str, command: &str, context: &str) -> usize {
assert!(
listed.is_empty() || listed.ends_with('\n'),
"{context}: {listed:?}"
);
assert!(!listed.contains(['\r', '\0']), "{context}: {listed:?}");
listed
.lines()
.map(|line| {
let rest = line
.strip_prefix(&format!("command=\"{command}"))
.unwrap_or_else(|| panic!("{context}: {line:?} isn't forced"));
let (flags, key) = rest
.split_once("\",restrict ")
.unwrap_or_else(|| panic!("{context}: {line:?} isn't restricted"));
let actors: Vec<&str> = flags.split(" --actor ").skip(1).collect();
assert!(
flags.starts_with(" --actor ")
&& actors
.iter()
.all(|did| AccountDid::new(*did).is_ok_and(|ok| ok.as_str() == *did)),
"{context}: flags {flags:?}"
);
assert_key_text(key, context);
key.to_string()
})
.collect::<BTreeSet<_>>()
.len()
}
#[test]
fn line_of_drops_a_key_whose_algorithm_could_break_its_line() {
let good = knot_types::OfferedKey::from_bytes(
[ssh_string(b"ssh-ed25519"), ssh_string(&[7; 32])].concat(),
);
let line = crate::keys::line_of(&good).unwrap();
assert_key_text(&line, "ssh-ed25519");
assert_eq!(
knot_atproto::parse_authorized_key(&line).unwrap(),
good,
"the line is the key it came from"
);
for algorithm in [
&b"ssh-ed25519\n"[..],
b"ssh-ed25519\ncommand=\"sh\" ssh-ed25519",
b"ssh-ed25519 AAAA",
b"ssh-ed25519\"",
b"ssh-ed25519,command=\"sh\"",
b"ssh-ed25519\0",
b"ssh-ed25519\r",
b"ssh-ed25519\t",
b"ssh-\xc3\xa9",
b"ssh-\\",
b"rsa",
b"",
] {
let key = knot_types::OfferedKey::from_bytes(
[ssh_string(algorithm), ssh_string(&[7; 32])].concat(),
);
assert_eq!(crate::keys::line_of(&key), None, "{algorithm:?}");
}
for truncated in [
&b""[..],
b"\0\0\0",
b"\0\0\0\x0bssh",
b"\xff\xff\xff\xffssh-ed25519",
] {
let key = knot_types::OfferedKey::from_bytes(truncated.to_vec());
assert_eq!(crate::keys::line_of(&key), None, "{truncated:?}");
}
}
#[test]
fn keys_list_drops_a_stored_line_that_isnt_exactly_one_key() {
let root = tempfile::tempdir().unwrap();
let config = keys_config(root.path());
let good = blob_line(b"ssh-ed25519", &[1; 32]);
let forged = [
format!("{good}\ncommand=\"sh\" {good}"),
format!("{good}\r"),
format!("{good} comment"),
format!("{good}\" {good}"),
format!("command=\"sh\" {good}"),
format!("ssh-ed25519\"x {}", &good["ssh-ed25519 ".len()..]),
format!("ssh-ed25519\t{}", &good["ssh-ed25519 ".len()..]),
format!("ssh-ed25519 {}", &good["ssh-ed25519 ".len()..]),
"ssh-ed25519".to_string(),
"ssh-ed25519 ".to_string(),
String::new(),
"\n".to_string(),
];
store_keys(
&config,
&[(
&config.owner,
forged.iter().cloned().chain([good.clone()]).collect(),
)],
);
let listed = crate::keys::list(&config, "/git-ssh").unwrap();
assert_eq!(assert_forced(&listed, "/git-ssh", "stored"), 1, "{listed}");
assert_eq!(
listed,
format!(
"command=\"/git-ssh --actor {}\",restrict {good}\n",
config.owner
)
);
}
#[test]
fn keys_list_refuses_a_command_that_could_leave_its_quotes() {
let root = tempfile::tempdir().unwrap();
let config = keys_config(root.path());
for command in ["/x\"", "/x\\", "/x\n", "/x\r", "/x\0", "/x\t"] {
assert!(crate::keys::list(&config, command).is_err(), "{command:?}");
}
assert!(crate::keys::list(&config, "/etc/knot-static/git-ssh").is_ok());
}
/// tokens that mean something to sshd's authorized_keys parser or to a shell.
const HOSTILE: &[&str] = &[
"ssh-",
"ed25519",
"ecdsa-",
"sha2-nistp256",
"sk-",
"@openssh.com",
"rsa",
"AAAA",
"C3Nz",
"aC1l",
"ZDI1",
"NTE5",
"=",
"==",
"+",
"/",
" ",
" ",
"\n",
"\r",
"\t",
"\"",
"\\",
",",
"#",
"command=\"sh\"",
"restrict",
"no-pty",
"environment=\"A=b\"",
"\0",
"é",
"\u{2028}",
"$(id)",
"`id`",
";",
"--actor",
"--operator",
"did:plc:x",
];
fn hostile(rng: &mut crate::test_rng::Rng) -> String {
(0..rng.range(0, 8)).map(|_| *rng.pick(HOSTILE)).collect()
}
const ALGORITHMS: &[&[u8]] = &[
b"ssh-ed25519",
b"ssh-rsa",
b"ecdsa-sha2-nistp256",
b"sk-ssh-ed25519@openssh.com",
];
fn genuine(rng: &mut crate::test_rng::Rng) -> String {
let algorithm = *rng.pick(ALGORITHMS);
let material: Vec<u8> = (0..rng.range(1, 40)).map(|_| rng.next() as u8).collect();
blob_line(algorithm, &material)
}
#[test]
fn keys_list_fuzz_every_line_stays_one_forced_restricted_key() {
let root = tempfile::tempdir().unwrap();
let config = keys_config(root.path());
let accounts: Vec<AccountDid> = ["did:plc:owner", "did:plc:alice", "did:web:bob.test"]
.into_iter()
.map(|did| AccountDid::new(did).unwrap())
.collect();
for seed in 0..400u64 {
let rng = &mut crate::test_rng::Rng(seed);
let mut genuine_keys = BTreeSet::new();
let stored: Vec<(&AccountDid, Vec<String>)> = accounts
.iter()
.map(|account| {
let lines = (0..rng.range(0, 4))
.map(|_| match rng.below(3) {
0 => {
let line = genuine(rng);
genuine_keys.insert(line.clone());
line
}
1 => hostile(rng),
_ => format!("{}{}", genuine(rng), hostile(rng)),
})
.collect();
(account, lines)
})
.collect();
store_keys(&config, &stored);
let context = format!("seed {seed}");
let listed = crate::keys::list(&config, "/etc/knot-static/git-ssh").unwrap();
let count = assert_forced(&listed, "/etc/knot-static/git-ssh", &context);
assert_eq!(
count,
listed.lines().count(),
"{context}: a key listed twice"
);
for key in &genuine_keys {
assert!(
listed.contains(&format!(",restrict {key}\n")),
"{context}: {key} is gone"
);
}
}
}
#[test]
fn keys_sync_fuzz_a_pds_record_never_becomes_more_than_one_key() {
for seed in 0..5000u64 {
let rng = &mut crate::test_rng::Rng(seed);
let record = match rng.below(3) {
0 => hostile(rng),
1 => format!("{}{}", genuine(rng), hostile(rng)),
_ => {
let mut algorithm = hostile(rng).into_bytes();
algorithm.retain(|b| !b.is_ascii_whitespace());
blob_line(&algorithm, b"material")
}
};
let Ok(key) = knot_atproto::parse_authorized_key(&record) else {
continue;
};
if let Some(line) = crate::keys::line_of(&key) {
assert_key_text(&line, &format!("seed {seed}: {record:?}"));
assert_eq!(knot_atproto::parse_authorized_key(&line).unwrap(), key);
}
}
}