Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927//! The SSH keys an account pushes with: the ones Tangled has on file, and the//! ones this machine can offer.//!//! Registered keys are public `sh.tangled.publicKey` records on the PDS, so we//! can match them against local candidates and pin the winner with per-repo//! `core.sshCommand` — no changes to the user's SSH config, and plain//! `git push` keeps working afterwards.//!//! Both halves have to line up or a push cannot be authenticated at all, and//! the two ways they fail to are different problems with different fixes: an//! account with no records at all has never published a key, while an account//! with records none of which is here has published one from another machine.//! [`PushKey`] keeps them apart so the caller can say which it is, and//! [`crate::cmd::key`] is the command that fixes either.//!//! Reads only. Writing a `sh.tangled.publicKey` record is `key add`, and//! it lives in [`crate::cmd::key`] with the rest of the command surface.
use anyhow::{Context, Result, bail};use std::path::{Path, PathBuf};
/// "type blob" with the comment stripped, for comparison.pub fn normalize(line: &str) -> Option<String> { let mut parts = line.split_whitespace(); match (parts.next(), parts.next()) { (Some(t), Some(b)) if t.starts_with("ssh-") || t.starts_with("ecdsa-") => { Some(format!("{t} {b}")) } _ => None, }}
/// One `sh.tangled.publicKey` record, as both the matcher and `key list`/// want it: the comparable key, and enough of the record to name it to a/// person who has to choose one.#[derive(Debug, Clone)]pub struct Registered { /// The record key, which is what `key delete` addresses. pub rkey: String, /// "type blob", comment stripped — the form that compares equal to a /// local copy of the same key. See [`normalize`]. pub key: String, /// What the owner called it. Optional here though the lexicon asks for /// one, because a record written by something else may not have it. pub name: Option<String>, pub created_at: Option<String>,}
impl Registered { /// The OpenSSH SHA-256 fingerprint, or the key type alone if the blob /// does not decode. See [`fingerprint`]. pub fn fingerprint(&self) -> String { fingerprint(&self.key).unwrap_or_else(|| "(unreadable key)".to_string()) }
/// `ssh-ed25519`, `ssh-rsa` — the first word of the key. pub fn kind(&self) -> &str { self.key.split_whitespace().next().unwrap_or("?") }}
/// Pull the keys out of a page of `sh.tangled.publicKey` records. Anything/// that doesn't parse as a key is dropped rather than failing the lookup: one/// malformed record should not cost the account its other keys.fn keys_from_records(records: &[serde_json::Value]) -> Vec<Registered> { records .iter() .filter_map(|r| { let raw = r["value"]["key"].as_str()?; Some(Registered { rkey: crate::clients::atproto::pds::rkey(r).to_string(), key: normalize(raw)?, name: r["value"]["name"].as_str().map(String::from), created_at: r["value"]["createdAt"].as_str().map(String::from), }) }) .collect()}
/// Every key an account has registered, newest last as the PDS lists them.////// Unauthenticated, like every other read in [`crate::clients::atproto::pds`]: this works for/// an account whose token expired and for accounts nobody here has logged in/// to, which is what lets `key list <someone>` answer at all.pub async fn registered_keys(pds: &str, did: &str) -> Result<Vec<Registered>> { // Whole, because both callers reason from absence: `key list` prints the // set, and the push-key lookup concludes "no machine can push as this // account" from finding none. Neither may be answered off a screenful. let listing = crate::clients::atproto::pds::list_matching( pds, did, crate::lexicon::tangled::PUBLIC_KEY_NSID, crate::clients::atproto::pds::Reach::Whole, |_| true, ) .await?; let keys = keys_from_records(&listing.records); crate::logging::debug::log(format!("{} registered key(s) on the PDS", keys.len())); if listing.evidence.truncated { crate::term::say::warning!( Ssh, "stopped after {} pages of registered keys; some may be missing", listing.evidence.reach.pages() ); } Ok(keys)}
/// What the push-key lookup found, and specifically which way it came up/// empty.////// The two empty answers are not the same problem. No records at all means/// the account has never published a key and *no* machine can push as it; a/// record that doesn't match means the key it published lives somewhere else./// Both end in a knot refusing the push, and telling them apart is the/// difference between "run `key add`" and "run it *here*".#[derive(Debug)]pub enum PushKey { /// A local key matching a registered record, as a path for `ssh -i`. Matched { identity: PathBuf, /// The record it matched, for saying which key was picked. registered: Box<Registered>, }, /// The account has registered no keys at all. NoneRegistered, /// Keys are registered; none of them is on this machine. NoLocalMatch { registered: usize },}
impl PushKey { /// The path to hand `ssh -i`, if there is one. pub fn identity(&self) -> Option<&Path> { match self { PushKey::Matched { identity, .. } => Some(identity), _ => None, } }
/// The key a push will use, named from both ends: the file it will sign /// with, and the record on the account that file answers to. Both halves /// are the point — a machine with several accounts' keys on it has more /// than one plausible answer, and "which one did it pick" is the question /// this exists to settle. pub fn describe(&self) -> Option<String> { match self { PushKey::Matched { identity, registered, } => Some(format!( "{} (registered as {})", identity.display(), registered .name .clone() .unwrap_or_else(|| registered.fingerprint()) )), _ => None, } }
/// Why pushing as `who` will not authenticate, and what to do about it — /// or `None` when it will. /// /// One string with the remedy on its own line, because both callers print /// it with a prefix of their own: `repo create` fails with it, `repo /// clone` and `repo configure` warn with it. What it must never do is /// give the same advice for both empty cases; see [`PushKey`]. pub fn problem(&self, who: &str) -> Option<String> { match self { PushKey::Matched { .. } => None, PushKey::NoneRegistered => Some(format!( "no SSH key is registered for {who}, so a knot will refuse every push from it\n\ publish this machine's key first: atgc key add" )), PushKey::NoLocalMatch { registered } => Some(format!( "{who} has {registered} registered SSH key(s), and none of them is on this \ machine\n(looked in ~/.ssh and in the ssh agent)\n\ publish this machine's key too: atgc key add" )), } }}
/// Every public key file in `dir`, as (the `.pub` path, its comparable form).////// Split out from the scan so the matching can be tested against a directory/// of real generated keys: `HOME` cannot be redirected in this crate's tests/// (see [`crate::docs::testing`]), but a directory that is passed in can be anywhere.fn keys_in(dir: &Path) -> Vec<(PathBuf, String)> { let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); }; let mut found: Vec<(PathBuf, String)> = entries .flatten() .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|e| e == "pub")) .filter_map(|path| { let contents = std::fs::read_to_string(&path).ok()?; Some((path, normalize(&contents)?)) }) .collect(); // Directory order is whatever the filesystem says, which makes the key a // repo gets pinned to depend on inode order. Sorting makes a re-run pick // the same key twice. found.sort(); found}
/// The path to hand `ssh -i` for a public key file: the private half when it/// is there, and the `.pub` itself when it is not — ssh accepts that form/// when the private half lives in the agent.fn identity_for(pub_path: &Path) -> PathBuf { let private = pub_path.with_extension(""); if private.exists() { private } else { pub_path.to_path_buf() }}
/// The first key in `dir` that `registered` contains, as an `ssh -i` path.fn identity_in(dir: &Path, registered: &[Registered]) -> Option<(PathBuf, Registered)> { for (path, local) in keys_in(dir) { match registered.iter().find(|r| r.key == local) { Some(hit) => { crate::logging::debug::log(format!("matched registered key: {}", path.display())); return Some((identity_for(&path), hit.clone())); } None => crate::logging::debug::log(format!("no match: {}", path.display())), } } None}
/// The same, over the keys the ssh agent is holding. A key that is only in/// the agent has no file to point `ssh -i` at, so the public half is written/// into atgc's config directory to be one.fn identity_from_ssh_agent(did: &str, registered: &[Registered]) -> Option<(PathBuf, Registered)> { let out = std::process::Command::new("ssh-add") .arg("-L") .output() .ok()?; if !out.status.success() { return None; } for line in String::from_utf8_lossy(&out.stdout).lines() { let Some(local) = normalize(line) else { continue; }; let Some(hit) = registered.iter().find(|r| r.key == local) else { continue; }; let dir = crate::config::dir::config_dir().ok()?; let path = write_ssh_agent_key(&dir, did, line).ok()?; crate::logging::debug::log(format!( "matched ssh-agent-held key, wrote {}", path.display() )); return Some((path, hit.clone())); } None}
/// Where an ssh-agent-held key's public half is parked, so `ssh -i` has a file/// to name.////// In the identity's own directory, so two atgc processes pushing as the same/// account write the same path — which is the whole of why the write below/// has to be atomic — and two *different* accounts never write the same one.////// It used to be `push-key-<the DID's last colon segment>.pub` at the top of/// the configuration directory, which drops the method: `did:plc:alice` and/// `did:web:alice` shared one parked file, and one account could hand/// `ssh -i` the other's key. The identity directory keeps the method, is/// derived through `split_did` -- the one place that decides what a DID may/// become on disk -- and is where the rest of an identity's keys already/// live, which is where a reader will look for this one.////// Takes the configuration directory rather than resolving it, since `HOME`/// cannot be redirected inside a test here (see [`crate::docs::testing`]).fn ssh_agent_key_path_in(config_dir: &Path, did: &str) -> Result<PathBuf> { Ok(crate::config::dir::identity_path_in(config_dir, did)?.join(PARKED_KEY))}
/// The parked public half, inside an identity's directory.const PARKED_KEY: &str = "push-key.pub";
/// Park the public half of an ssh-agent-held key and answer where it went.////// Through `write_atomic` rather than `fs::write`, which is what this was:/// `fs::write` truncates and then writes, so a second atgc pushing as the/// same account could hand `ssh -i` a file that was empty or half a key at/// the instant ssh opened it, and the push failed with an ssh error naming/// nothing that would explain it. Several atgc processes against one `$HOME`/// is the ordinary case now. `write_atomic` stages a sibling temp file whose/// name no other process can pick and renames it over this path, so a reader/// sees the whole of one version or the whole of another — and since every/// writer here writes the same key for the same DID, either version works.////// 0600, like everything else atgc puts in this directory. Nothing in a/// public key is secret and ssh asks for no particular mode on the public/// half, but the directory is 0700 already so a looser file buys no one/// access, and being the one exception is how a mode gets copied somewhere/// it matters.fn write_ssh_agent_key(config_dir: &Path, did: &str, line: &str) -> Result<PathBuf> { // Created first: this may be the first thing an identity has ever had // written for it, and `write_atomic` stages a sibling temp file that needs // the directory to be there already. The path still comes from the one // function that names it -- computing it here as well is how the two // drift apart. crate::config::dir::identity_dir_in(config_dir, did)?; let path = ssh_agent_key_path_in(config_dir, did)?; crate::config::dir::write_atomic(&path, format!("{line}\n").as_bytes(), 0o600)?; Ok(path)}
/// The private key atgc keeps for `did`, and its public half, if it made one.////// Names the pair without touching the disk, so a caller can ask whether one/// exists before deciding to make it.pub fn agent_key_paths(dir: &Path) -> (PathBuf, PathBuf) { let private = dir.join("id_ed25519"); let public = dir.join("id_ed25519.pub"); (private, public)}
/// Generate an ed25519 keypair for `did` in `dir`, and answer where it went.////// Shells out to `ssh-keygen` rather than generating in process, and the/// reason is not laziness about the crypto. `ed25519-dalek` is already in the/// tree, but writing the *OpenSSH private key format* around it is the actual/// work, and it would mean a new direct dependency to clear `deny.toml` for —/// which this crate has declined for smaller things. Against that: a key with/// no `ssh` to use it is worthless, `ssh-keygen` ships with the `ssh` that/// every push here already requires, and being unable to find it is a failure/// with an obvious repair rather than a mystery.////// No passphrase, and that is the point rather than a shortcut: this key/// exists so an unattended identity can push, and a passphrase it would have/// to store beside the key is not a passphrase. It is written 0600 in a 0700/// directory, which is the protection `sessions.json` already relies on for/// the DPoP keys sitting next to it.pub fn generate_agent_key(dir: &Path, comment: &str) -> Result<(PathBuf, PathBuf)> { let (private, public) = agent_key_paths(dir); if private.exists() { bail!("a key is already here: {}", private.display()); } let out = std::process::Command::new("ssh-keygen") .args(["-q", "-t", "ed25519", "-N", "", "-C", comment, "-f"]) .arg(&private) .output() .context( "could not run ssh-keygen, which atgc uses to make a key; it ships with OpenSSH, \ the same package as the `ssh` a push needs", )?; if !out.status.success() { bail!( "ssh-keygen failed: {}", String::from_utf8_lossy(&out.stderr).trim() ); } // ssh-keygen writes 0600 itself, but it is one umask away from not, and // the file is the whole secret. Asserting the mode costs a `stat`. crate::config::dir::narrow_file_if_wide(&private); Ok((private, public))}
/// Which local key an account can push with, or why it has none.////// The lookup every command that touches a Tangled remote does before/// trusting one: `repo create` refuses over it, `repo clone` picks a/// transport by it, and `repo configure` pins what it returns.pub async fn push_key(pds: &str, did: &str) -> Result<PushKey> { let registered = registered_keys(pds, did).await?; if registered.is_empty() { return Ok(PushKey::NoneRegistered); }
// atgc's own key for this account first, then `~/.ssh`, then keys held // only by the ssh agent. // // First because it is the least ambiguous: it was made for this DID, it // lives in a directory named after this DID, and if it is registered then // it is beyond doubt the key this account pushes with. `~/.ssh` is a // person's drawer of keys that happens to be searched for a match, which // is the right second guess and a poor first one on a machine where // several identities are pushing. let found = agent_identity(did, ®istered) .or_else(|| match std::env::var_os("HOME") { Some(home) => identity_in(&PathBuf::from(&home).join(".ssh"), ®istered), None => None, }) .or_else(|| identity_from_ssh_agent(did, ®istered));
Ok(match found { Some((identity, hit)) => PushKey::Matched { identity, registered: Box::new(hit), }, None => PushKey::NoLocalMatch { registered: registered.len(), }, })}
/// atgc's own key for `did`, if it made one and it is registered.////// Registration is checked and not assumed: a key can be generated here and/// the publish that follows can fail, and a local file is not permission to/// push. Nothing is repaired — the caller's business is what to push with/// now, and a key nobody registered is exactly as useless as no key.fn agent_identity(did: &str, registered: &[Registered]) -> Option<(PathBuf, Registered)> { let dir = crate::config::dir::config_dir().ok()?; agent_identity_in(&dir, did, registered)}
/// [`agent_identity`], with the configuration directory passed in, for the/// same reason [`crate::config::dir::identity_dir_in`] is split out.fn agent_identity_in( config_dir: &Path, did: &str, registered: &[Registered],) -> Option<(PathBuf, Registered)> { let dir = crate::config::dir::identity_path_in(config_dir, did).ok()?; let (private, public) = agent_key_paths(&dir); let line = std::fs::read_to_string(&public).ok()?; let local = normalize(&line)?; let hit = registered.iter().find(|r| r.key == local)?; private.exists().then(|| (private, hit.clone()))}
/// Every public key file in `~/.ssh`, for a command that has to offer a/// choice rather than make one. Empty when `HOME` is unset or unreadable.pub fn local_keys() -> Vec<(PathBuf, String)> { match std::env::var_os("HOME") { Some(home) => keys_in(&PathBuf::from(&home).join(".ssh")), None => Vec::new(), }}
/// The OpenSSH fingerprint of a key: `SHA256:` and the digest of the raw/// blob in unpadded standard base64, which is what `ssh-keygen -lf` prints/// and what Tangled's key list shows.////// `None` if the blob is not base64, which for a line that has already been/// through [`normalize`] means a key type we recognize carrying something/// that is not a key.pub fn fingerprint(line: &str) -> Option<String> { use sha2::{Digest, Sha256}; let blob = base64_decode(line.split_whitespace().nth(1)?)?; Some(format!( "SHA256:{}", base64_encode(&Sha256::digest(&blob), false) ))}
const STANDARD: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Standard-alphabet base64, padding optional on the way in.////// Hand-rolled because there is no base64 crate in the tree — the same/// reason `crate::logging::debug` has its own base64url decoder, and deliberately not/// shared with it: that one decodes JWT segments, which are url-safe and/// never padded, and a decoder that quietly accepted both alphabets would/// make a key blob and a token segment interchangeable when they are not.fn base64_decode(text: &str) -> Option<Vec<u8>> { let mut out = Vec::with_capacity(text.len() * 3 / 4); let mut acc: u32 = 0; let mut bits = 0; for &c in text.as_bytes() { if c == b'=' { break; } let v = STANDARD.iter().position(|&a| a == c)? as u32; acc = (acc << 6) | v; bits += 6; if bits >= 8 { bits -= 8; out.push((acc >> bits) as u8); } } Some(out)}
/// The other direction. `pad` is false for a fingerprint, which OpenSSH/// prints unpadded.fn base64_encode(bytes: &[u8], pad: bool) -> String { let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { let b = [ chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0), ]; let n = u32::from_be_bytes([0, b[0], b[1], b[2]]); for i in 0..chunk.len() + 1 { out.push(STANDARD[((n >> (18 - 6 * i)) & 0x3f) as usize] as char); } if pad { for _ in chunk.len()..3 { out.push('='); } } } out}
#[cfg(test)]mod tests { use super::{ PushKey, Registered, agent_identity_in, agent_key_paths, base64_decode, base64_encode, fingerprint, generate_agent_key, identity_in, keys_from_records, keys_in, normalize, ssh_agent_key_path_in, write_ssh_agent_key, }; use crate::testutil::TempKeys; use serde_json::json; use std::path::Path;
/// A real `sh.tangled.publicKey` listRecords response. const KEYS: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/public_keys.json" ));
const ED25519: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDDsetAnI2dae8F++3sXGbr7cMZgqJ3itIh3w8VA78hE";
fn registered(keys: &[&str]) -> Vec<Registered> { keys.iter() .enumerate() .map(|(i, k)| Registered { rkey: format!("rkey{i}"), key: normalize(k).expect("a key line"), name: Some(format!("key {i}")), created_at: None, }) .collect() }
/// The comment is the whole reason this normalization exists: the same /// key is `... user@laptop` in `~/.ssh/id_ed25519.pub`, `... permadeath.com /// (tangled)` in the PDS record, and often has no comment at all coming /// out of `ssh-add -L`. Only type and blob are the key. #[test] fn strips_the_comment_so_the_same_key_compares_equal() { let from_pds = format!("{ED25519} permadeath.com (tangled)"); let from_disk = format!("{ED25519} jmm@laptop"); let from_agent = ED25519.to_string();
assert_eq!(normalize(&from_pds), Some(ED25519.to_string())); assert_eq!(normalize(&from_disk), Some(ED25519.to_string())); assert_eq!(normalize(&from_agent), Some(ED25519.to_string())); assert_eq!(normalize(&from_pds), normalize(&from_disk)); }
/// A `.pub` file read off disk arrives with its trailing newline, and /// `ssh-add -L` pads its columns. Splitting on whitespace absorbs both. #[test] fn tolerates_the_whitespace_real_key_sources_carry() { let with_newline = format!("{ED25519} comment\n"); assert_eq!(normalize(&with_newline), Some(ED25519.to_string())); assert_eq!( normalize(&format!(" {ED25519} comment ")), Some(ED25519.to_string()) ); }
#[test] fn accepts_the_key_types_it_recognizes() { for kind in ["ssh-ed25519", "ssh-rsa", "ssh-dss", "ecdsa-sha2-nistp256"] { let line = format!("{kind} AAAABLOB comment"); assert_eq!(normalize(&line), Some(format!("{kind} AAAABLOB"))); } }
/// Everything that is not a key line. `sk-ssh-ed25519@openssh.com` — a /// FIDO-backed key — is in here because the prefix check does not cover /// it: such a key would silently never match, which is worth knowing. #[test] fn rejects_what_is_not_a_key_line() { for line in [ "", " ", "# a comment", // Type but no blob. "ssh-ed25519", "The agent has no identities.", "sk-ssh-ed25519@openssh.com AAAABLOB comment", ] { assert_eq!(normalize(line), None, "input: {line:?}"); } }
#[test] fn reads_the_keys_out_of_a_live_listrecords_response() { let response: serde_json::Value = serde_json::from_str(KEYS).unwrap(); let records = response["records"].as_array().unwrap(); let keys = keys_from_records(records); assert_eq!(keys.len(), 1, "fixture has one registered key"); assert_eq!(keys[0].key, ED25519); assert_eq!(keys[0].rkey, "3mrxk5molrp22"); assert_eq!(keys[0].name.as_deref(), Some("permadeath.com push key"));
// Which is the match a local candidate has to make: same key, but // the local copy carries whatever comment the machine gave it. let local = normalize(&format!("{ED25519} jmm@somewhere-else")).unwrap(); assert!(keys.iter().any(|k| k.key == local)); }
/// An empty result is not an error — [`super::push_key`] reads it as /// [`PushKey::NoneRegistered`], which is a diagnosis rather than a /// failure. A malformed record among good ones is dropped on its own. (A /// response with no `records` array at all is `crate::clients::atproto::pds`'s to absorb, /// and it does.) #[test] fn survives_responses_with_nothing_usable_in_them() { assert!(keys_from_records(&[]).is_empty()); assert!(keys_from_records(&[json!({ "value": {} })]).is_empty());
let mixed = [ json!({ "value": { "key": "garbage" } }), json!({ "value": { "key": format!("{ED25519} good") } }), json!({ "value": { "key": null } }), ]; let keys = keys_from_records(&mixed); assert_eq!(keys.len(), 1); assert_eq!(keys[0].key, ED25519); }
/// A real keypair, generated for the test, found in a directory that is /// not `~/.ssh` — which is the only way this can be tested at all, since /// `HOME` is not redirectable here ([`crate::docs::testing`]). /// /// The private half is what comes back, not the `.pub` that matched: /// `ssh -i` wants the key it will sign with. #[test] fn finds_the_generated_key_the_account_has_registered() { let keys = TempKeys::new("match"); let found = identity_in(keys.dir(), ®istered(&[&keys.public_line()])); let (identity, hit) = found.expect("the generated key is registered, so it must match"); assert_eq!(identity, keys.private()); assert_eq!(hit.rkey, "rkey0"); }
/// The same directory, against an account that registered a different /// key. This is the case that used to be a warning and is now a refusal: /// the key is here, Tangled has never heard of it, and the push would be /// rejected by the knot. #[test] fn a_key_the_account_never_registered_is_not_a_match() { let keys = TempKeys::new("nomatch"); let other = TempKeys::new("other"); assert!(identity_in(keys.dir(), ®istered(&[&other.public_line()])).is_none()); // And an account with no records at all matches nothing either. assert!(identity_in(keys.dir(), &[]).is_none()); }
/// Only `.pub` files are candidates, and the private key sitting beside /// them is not one. Reading a private key as a candidate would put its /// path in a debug log and compare secret material against a public /// record, and it can never match anyway. #[test] fn only_public_key_files_are_candidates() { let keys = TempKeys::new("candidates"); std::fs::write( keys.dir().join("known_hosts"), "example.com ssh-ed25519 AAAA\n", ) .unwrap(); std::fs::write(keys.dir().join("config"), "Host *\n").unwrap();
let found = keys_in(keys.dir()); assert_eq!(found.len(), 1, "one .pub in the directory: {found:?}"); assert_eq!(found[0].0, keys.public()); assert_eq!(found[0].1, normalize(&keys.public_line()).unwrap()); }
/// A generated key is a real, usable ed25519 pair, private half 0600. /// /// Run against `ssh-keygen` itself rather than a fixture, because the /// agreement with it is the thing under test: the public half has to be a /// line [`normalize`] accepts, or the publish that follows sends nothing a /// knot will ever match. #[test] fn a_generated_key_is_a_usable_pair_at_0600() { let dir = tempfile::tempdir().expect("a temp dir"); let (private, public) = generate_agent_key(dir.path(), "@agent.example (atgc)").expect("ssh-keygen ran");
assert!(private.exists() && public.exists()); assert_eq!( agent_key_paths(dir.path()), (private.clone(), public.clone()) );
let line = std::fs::read_to_string(&public).expect("the public half"); let normalized = normalize(&line).expect("a key atgc can read back"); assert!(normalized.starts_with("ssh-ed25519 "), "{normalized}"); assert!(fingerprint(&normalized).is_some()); assert!(line.contains("@agent.example (atgc)"), "{line}");
#[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(&private).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "the private half is 0600"); }
// Twice is a refusal, not a silent overwrite: regenerating orphans the // registered public half and destroys the only copy of the key the // account can push with. assert!( generate_agent_key(dir.path(), "@agent.example (atgc)").is_err(), "a second generate must not clobber the first" ); }
/// The key is offered only once registered, and only for its own DID. /// /// A local file is not permission to push: generating and publishing are /// two steps and the second can fail. Answering with an unregistered key /// points `ssh -i` at something the knot rejects, which is a worse failure /// than having no key at all. #[test] fn an_unregistered_or_foreign_key_is_not_offered() { let config = tempfile::tempdir().expect("a temp dir"); const DID: &str = "did:plc:agentone"; let dir = crate::config::dir::identity_dir_in(config.path(), DID).expect("a key dir"); // The directory mode is the protection that matters. ssh-keygen // writes the public half 0644 and there is no reason it should not, // but the *private* half's neighbours being readable is how a key // leaks — and `dids/` and `dids/<method>/` are created on the way to // it, so a umask must not decide any of the three. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; for level in [ dir.as_path(), dir.parent().unwrap(), dir.parent().unwrap().parent().unwrap(), ] { let mode = std::fs::metadata(level).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o700, "{} is not owner-only", level.display()); } } let (_private, public) = generate_agent_key(&dir, "@agent.example (atgc)").unwrap(); let line = normalize(&std::fs::read_to_string(&public).unwrap()).unwrap();
assert!( agent_identity_in(config.path(), DID, &[]).is_none(), "nothing registered, nothing offered" );
let registered = vec![Registered { rkey: "abc".into(), key: line, name: Some("agent".into()), created_at: None, }]; assert!( agent_identity_in(config.path(), DID, ®istered).is_some(), "a registered key is offered" ); assert!( agent_identity_in(config.path(), "did:plc:agenttwo", ®istered).is_none(), "another account's directory is not consulted for this one" ); }
/// The fingerprint has to be the one `ssh-keygen -lf` prints, because /// that is what a person compares against Tangled's key list when /// deciding whether the key in front of them is the one that is /// registered. Checked against ssh-keygen itself on a generated key /// rather than against a value pasted in here. #[test] fn fingerprints_a_generated_key_the_way_ssh_keygen_does() { let keys = TempKeys::new("fingerprint"); assert_eq!( fingerprint(&keys.public_line()).as_deref(), Some(keys.fingerprint().as_str()) ); }
/// A key line that survived `normalize` can still carry a blob that is /// not base64 — the type is checked, the blob is not — and saying so is /// better than printing a fingerprint of the wrong bytes. #[test] fn refuses_to_fingerprint_what_is_not_a_key() { assert_eq!(fingerprint("ssh-ed25519 not!base64"), None); assert_eq!(fingerprint("ssh-ed25519"), None); }
/// The base64 codec, which is hand-rolled and therefore the kind of /// thing [`crate::docs::testing`] says earns a test on its own. Padded input is /// accepted (a `.pub` blob usually is) and unpadded output is what a /// fingerprint wants. #[test] fn round_trips_base64_with_and_without_padding() { for (bytes, encoded) in [ (&b""[..], ""), (&b"f"[..], "Zg=="), (&b"fo"[..], "Zm8="), (&b"foo"[..], "Zm9v"), (&b"foob"[..], "Zm9vYg=="), (&b"\xfb\xff"[..], "+/8="), ] { assert_eq!(base64_encode(bytes, true), encoded); assert_eq!(base64_decode(encoded).as_deref(), Some(bytes)); assert_eq!( base64_decode(encoded.trim_end_matches('=')).as_deref(), Some(bytes), "padding is optional on the way in" ); assert_eq!(base64_encode(bytes, false), encoded.trim_end_matches('=')); } // The url-safe alphabet is a different encoding and is not accepted // here; `crate::logging::debug` owns that one. assert_eq!(base64_decode("-_8"), None); }
/// The two empty answers must not give the same advice. "You have never /// published a key" and "you published one somewhere else" are fixed by /// running the same command in different places, and a message that /// conflates them sends someone to the wrong machine. #[test] fn each_way_of_having_no_key_says_something_different() { let none = PushKey::NoneRegistered .problem("@permadeath.games") .unwrap(); let unmatched = PushKey::NoLocalMatch { registered: 2 } .problem("@permadeath.games") .unwrap();
assert!(none.contains("no SSH key is registered"), "{none}"); assert!(unmatched.contains("2 registered SSH key(s)"), "{unmatched}"); assert!( unmatched.contains("none of them is on this machine"), "{unmatched}" ); assert_ne!(none, unmatched); // Both name the account and both end at the same remedy. for message in [&none, &unmatched] { assert!(message.contains("@permadeath.games"), "{message}"); assert!(message.contains("atgc key add"), "{message}"); } }
/// The path is a function of the DID alone, which is what the race test /// below rests on: two atgc processes pushing as the same account are two /// writers of one file, not two files. /// /// The second half is the half that failed. The name used to be built /// from the DID's last colon segment, which two different accounts can /// share -- and this test asserted only that one DID maps to itself, so /// it passed while two accounts collided on one parked key. #[test] fn the_parked_key_is_named_for_the_did_and_nothing_else() { let dir = Path::new("/home/someone/.config/atgc"); let path = |did| ssh_agent_key_path_in(dir, did).expect("a valid DID");
assert_eq!( path("did:plc:abc123"), dir.join("dids/plc/abc123/push-key.pub") ); assert_eq!(path("did:plc:abc123"), path("did:plc:abc123"));
// The reachable collision: the old name kept only the last segment, // so the method fell off and these two were one file. Path-form // `did:web` is refused upstream (see `split_did`), so this is the // shape that mattered. assert_ne!( path("did:plc:abc123"), path("did:web:abc123"), "two methods sharing an identifier shared a parked key" ); }
/// The defect: this file was written with `fs::write`, which truncates /// before it writes, so a concurrent atgc pushing as the same account /// could open it between the truncate and the write and hand `ssh -i` an /// empty or half-written key. /// /// Writers and a reader run at once over one path. The assertion is on /// every read: each one must see a whole key, never a fragment and never /// nothing. Two threads in one process race exactly as two processes do /// here, since neither takes a lock and both go through the filesystem. #[test] fn a_concurrent_push_never_reads_a_half_written_key() { let dir = tempfile::Builder::new() .prefix("atgc-ssh-key-race-") .tempdir() .unwrap(); let did = "did:plc:racer"; let path = ssh_agent_key_path_in(dir.path(), did).expect("a valid DID"); // Long enough that a truncate-then-write would leave a visibly short // file for most of its window rather than only for an instant. let key = format!("ssh-ed25519 {} someone@example", "A".repeat(4096)); write_ssh_agent_key(dir.path(), did, &key).expect("the first write lands");
let reads = std::sync::atomic::AtomicUsize::new(0); std::thread::scope(|scope| { for _ in 0..3 { scope.spawn(|| { for _ in 0..50 { write_ssh_agent_key(dir.path(), did, &key).expect("a write lands"); } }); } scope.spawn(|| { for _ in 0..200 { let seen = std::fs::read_to_string(&path).expect("the file is always there"); assert_eq!( seen.trim_end(), key, "a reader saw a truncated key: {} bytes", seen.len() ); reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); std::thread::yield_now(); } }); }); assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 200); }}