//! 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 { 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, pub created_at: Option, } 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 { 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 ` answer at all. pub async fn registered_keys(pds: &str, did: &str) -> Result> { // 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, }, /// 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 { 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 { 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-.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 { 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 { // 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 { 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 { 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> { 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 { 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//` 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); } }