Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996//! `key add`, `key list`, `key delete`: the `sh.tangled.publicKey`//! records a knot authorizes pushes against.//!//! This is the half of pushing that atgc could previously only complain//! about. [`crate::clients::git::ssh`] has always matched an account's registered keys//! against this machine's, but nothing here could *register* one, so an//! account whose keys were never published had no way forward inside the//! tool: the browser was the only place a key could be added, and the failure//! it caused: `ssh -T` answering "Hi there!" instead of naming the account,//! and every push refused: says nothing about keys at all.//!//! It matters most for the case atgc exists to support. One machine, several//! accounts: each account needs its own key on file, because a knot decides//! *who is pushing* from the key that authenticated the connection and from//! nothing else. A second account added with `auth login` is not able to push//! until this command has run for it, and no amount of `--account` or git//! config changes that.//!//! Reads are public and go through [`crate::clients::atproto::pds`]; the two writes go through//! jacquard's authenticated agent and need the `repo:sh.tangled.publicKey`//! scope, which is why adding these commands cost a re-login. See//! [`crate::docs::architecture`].
use crate::clients::git::ssh::{self, Registered};use crate::cmd::auth;use crate::config::account;use anyhow::{Context, Result, anyhow, bail};use jacquard::client::AgentSessionExt;use jacquard::types::recordkey::RecordKey;use jacquard::types::string::Datetime;use std::path::{Path, PathBuf};use tangled_lexicon::LexiconSchema;use tangled_lexicon::sh_tangled::public_key::{PublicKey, PublicKeyRecord};
/// The `atgc key` verbs.#[derive(clap::Subcommand, Debug)]pub(crate) enum Command { /// List the SSH keys registered for an account List(ListArgs), /// Publish a public key, so knots accept pushes signed by its private half Add(AddArgs), /// Make a key for this account and publish it, for an identity with no ~/.ssh /// /// For an agent. A person pushes with a key in their own `~/.ssh`; an /// agent identity has no person, no `~/.ssh` of its own, and nowhere good /// to put one — not the shared `~/.ssh`, which belongs to whoever owns the /// machine, and not the checkout, which is not gitignored. /// /// The pair goes in `<config>/dids/<method>/<id>/`, 0600 in a 0700 /// directory, beside the session store that already holds this account's /// DPoP key. Nothing pins it here: run `atgc repo configure` in the /// worktree that will push, which finds this key ahead of `~/.ssh` and /// writes `core.sshCommand` at that worktree's own scope. Create(CreateArgs), /// Withdraw a key, so pushes signed by it stop being accepted Delete(DeleteArgs),}
/// Run whichever `key` verb was parsed.pub(crate) async fn run(command: Command) -> Result<()> { match command { Command::List(args) => list(args).await, Command::Add(args) => add(args).await, Command::Create(args) => create(args).await, Command::Delete(args) => delete(args).await, }}
#[derive(clap::Args, Debug)]pub(crate) struct ListArgs { /// Whose keys to list: handle or DID (defaults to the active account) /// /// Named `owner` rather than `account` for the reason `repo list` gives: /// the global `--account` selector owns that argument id. /// /// A public read, so this need not be an account we hold a session for. #[arg(value_name = "HANDLE|DID")] pub owner: Option<String>, /// Print a JSON array instead of the listing, one object per key, each /// saying whether it is the key on this machine #[arg(long)] pub json: bool,}
/// One registered key, as `key list --json` prints it.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct KeyJson { /// The record key, which is what `key delete` addresses. pub rkey: String, /// What the owner called it. `null` for a record written without one: /// the text view prints `(unnamed)`, which is a label rather than a name. pub name: Option<String>, /// `ssh-ed25519`, `ssh-rsa`: the first word of the key line. pub kind: String, /// The OpenSSH SHA-256 fingerprint, `null` when the blob does not /// decode: the text view prints `(unreadable key)` there, which is a /// sentence and not a fingerprint. pub fingerprint: Option<String>, /// `type blob`, comment stripped: the form that compares equal to a /// local copy of the same key, and the form atgc stores. Not the record's /// raw `key` field, which may carry whatever comment the client that /// wrote it appended. pub key: String, pub created_at: Option<String>, /// The path of the matching public key on this machine, when there is /// one. The one thing a listing here can say that Tangled's web UI /// cannot: whether the key in front of you is the key you push with. pub on_this_machine: Option<String>,}
/// Build one [`KeyJson`] from a registered record and this machine's keys.////// Pure: `local` is the scan [`ssh::local_keys`] already did, passed in so a/// test can hold it still rather than reach into `~/.ssh`.fn key_json(key: &Registered, local: &[(PathBuf, String)]) -> KeyJson { KeyJson { rkey: key.rkey.clone(), name: key.name.clone(), kind: key.kind().to_string(), fingerprint: ssh::fingerprint(&key.key), key: key.key.clone(), created_at: key.created_at.clone(), on_this_machine: local .iter() .find(|(_, local)| *local == key.key) .map(|(path, _)| path.display().to_string()), }}
#[derive(clap::Args, Debug)]pub(crate) struct CreateArgs { /// What to call it on the account (defaults to the handle and `atgc`) #[arg(long)] pub name: Option<String>, /// Say what would be made and published without doing either #[arg(long)] pub dry_run: bool, /// Print one JSON object describing what was published (see `key add`) #[arg(long)] pub json: bool,}
#[derive(clap::Args, Debug)]pub(crate) struct AddArgs { /// The key: a path to a `.pub` file, or the key line itself /// /// Defaults to the one public key in ~/.ssh when there is exactly one. A /// path to a private key is read as the `.pub` beside it: private key /// material is never published. #[arg(value_name = "KEY|PATH")] pub key: Option<String>, /// What to call it (defaults to the key's own comment) #[arg(long)] pub name: Option<String>, /// Say what would be written without writing anything #[arg(long)] pub dry_run: bool, /// Print one JSON object describing what was written (or, with --dry-run, /// what would be) instead of the summary lines #[arg(long)] pub json: bool,}
#[derive(clap::Args, Debug)]pub(crate) struct DeleteArgs { /// Which key: its record key, name, fingerprint, or a path to it /// /// The key line itself is accepted too. #[arg(value_name = "KEY")] pub key: String, /// Say what would be deleted without deleting anything #[arg(long)] pub dry_run: bool, /// Print one JSON object describing what was written (or, with --dry-run, /// what would be) instead of the summary lines #[arg(long)] pub json: bool,}
/// What `key add` did, or would have done.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct AddedJson { pub dry_run: bool, /// The `sh.tangled.publicKey` record, `null` on a dry run and when the /// key was already registered. pub uri: Option<String>, /// `false` when this account already had this exact key on file. Adding /// it twice is not an error: the second run is usually somebody making /// sure, and this is what says which happened. pub added: bool, /// The record key of the existing registration, when there was one. pub existing_rkey: Option<String>, pub name: String, pub kind: String, pub fingerprint: Option<String>, /// Where the key was read from: a path, or the fact that it was given /// inline. pub source: String,}
/// What `key delete` did, or would have done.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct DeletedJson { pub dry_run: bool, pub deleted: bool, pub rkey: String, pub name: Option<String>, pub kind: String, pub fingerprint: Option<String>, /// True when this was the account's only key, after which nothing can /// push as it. The text view prints the same fact as a note. pub was_last: bool,}
/// Every key an account has registered, and which of them this machine holds.////// Unauthenticated throughout, like `repo list`: the records are public, so/// this answers for other people's accounts and for an account whose token/// lapsed. Both are worth having: "has that key propagated yet" is a/// question people ask about accounts they are not logged in to.pub(in crate::cmd) async fn list(args: ListArgs) -> Result<()> { crate::term::jsonout::init(args.json); let (did, handle) = account::owner(args.owner).await?; // `who` is the DID itself when no handle resolves, so the DID goes in // the parentheses only when it adds something the label did not say. let who = crate::term::hyperlink::account(handle.as_deref(), &did); let also = match handle.is_some() { true => format!(" ({did})"), false => String::new(), };
let pds = crate::clients::atproto::did::pds_or_fail(&did).await?; let keys = ssh::registered_keys(&pds, &did).await?;
if keys.is_empty() { // An account with no keys is an answer — `[]` — and the advice that // follows it is a note, not part of the answer. if args.json { crate::term::say::note!( Ssh, "{who}{also} has no registered SSH keys; no machine can push as it \ until one is added: atgc key add" ); println!("[]"); return Ok(()); } println!("{who}{also} has no registered SSH keys"); println!("no machine can push as it until one is added: atgc key add"); return Ok(()); }
let local = ssh::local_keys();
if args.json { let rows: Vec<KeyJson> = keys.iter().map(|key| key_json(key, &local)).collect(); crate::term::jsonout::emit(&rows)?; crate::term::say::note!(Ssh, "{who}{also}: {} registered SSH key(s)", keys.len()); if !rows.iter().any(|k| k.on_this_machine.is_some()) { crate::term::say::note!(Ssh, "none of these keys is on this machine: atgc key add"); } return Ok(()); }
println!("{who}{also}: {} registered SSH key(s)\n", keys.len()); for key in &keys { println!(" {}", key.name.as_deref().unwrap_or("(unnamed)")); println!( " {} {} added {} rkey {}", key.kind(), key.fingerprint(), // The date, not the timestamp: Tangled writes `createdAt` in // whatever offset the client that wrote it was in, so the time of // day is not comparable between two rows anyway. key.created_at .as_deref() .and_then(|t| t.split('T').next()) .unwrap_or("?"), key.rkey ); // The one thing a listing can say that the web UI cannot: whether the // key in front of you is the key on this machine. if let Some((path, _)) = local.iter().find(|(_, local)| *local == key.key) { println!(" on this machine: {}", path.display()); } println!(); } if !keys.iter().any(|k| local.iter().any(|(_, l)| *l == k.key)) { println!("none of these keys is on this machine: atgc key add"); } Ok(())}
/// Publish a public key so knots will accept pushes signed by its private/// half.////// The record's `key` field holds the whole `type blob comment` line as/// OpenSSH writes it, not just the local one written here. Tangled's own/// web UI puts a comment of its own on the end (`… permadeath.com (tangled)`),/// which is why nothing may compare these as strings; see [`ssh::normalize`]./// Make this account a key of its own, then publish it.////// Two steps that are each somebody else's job already: generation, which is/// [`ssh::generate_agent_key`], and publication, which is [`add`] — called/// here with the generated public half rather than reimplemented, so the/// dedupe, the naming, the `--json` shape and the record write have exactly/// one implementation between the two commands.////// It deliberately does not pin `core.sshCommand`. `repo configure` does that,/// it now looks in this directory before `~/.ssh`, and it is the only one of/// the two that knows which config scope a worktree wants. Splitting it the/// other way would put a git write in a command about an account.////// Running it twice is not an error. The second run finds the key already/// there, publishes nothing new — `add` treats an already-registered key as a/// no-op — and says so, which is what a bootstrap script re-run needs to do.async fn create(args: CreateArgs) -> Result<()> { crate::term::jsonout::init(args.json); let selection = account::select().await?; selection.announce();
// A person's key belongs in their own `~/.ssh`, where ssh, git and every // other tool already look for it. This directory is for identities that // have no person and no such drawer, and the refusal matters more than the // filing does: `push_key` prefers a key here over one in `~/.ssh`, so a // person who ran this once would have every later push for this account // signed by a key in a directory they would never think to look in, while // the one they know about sat unused. // Asked of the account, not of the environment: does this account have an // agent login at all? That is a fact recorded when somebody authorized it, // where "is this process an agent" was a guess re-made on every command — // and a wrong guess here writes a key into a directory nobody looks in. // // The gate is all this decides. Publishing the key still needs a session // on the plane this process is on, so a person cannot set an agent's key // up from their own side of an agent-only account — the write is refused // a moment later, and correctly. Provisioning it by hand means acting on // the agent's plane, which is what `auth login --agent` prepares the // machine for. if !account::has_grant(&selection.did, account::Plane::Agent) { return Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "{} has no agent login, and this command is for identities with no \ ~/.ssh of their own\n\ for a person's key: `ssh-keygen -t ed25519` and then `atgc key add`, which \ leaves it where ssh and git already look\n\ to give this account an agent login: `atgc auth login --agent {}`", selection.display(), selection.handle.as_deref().unwrap_or(&selection.did), ), )); }
let dir = crate::config::dir::identity_dir(&selection.did)?; let (private, public) = ssh::agent_key_paths(&dir); let comment = format!("{} (atgc)", selection.display());
match (private.exists(), args.dry_run) { (true, _) => crate::term::say::step!(Ssh, "key already made: {}", private.display()), (false, true) => { crate::term::say::step!(Ssh, "would make an ed25519 key at {}", private.display()); // Without a key there is nothing to hand `add`, and inventing a // fingerprint for the preview would be a preview of a different // key. What the publish would do is `key add --dry-run` once one // exists, and saying so beats printing something untrue. crate::term::say::note!( Ssh, "and then publish it; re-run without --dry-run to see the key it published" ); return Ok(()); } (false, false) => { ssh::generate_agent_key(&dir, &comment)?; crate::term::say::step!(Ssh, "made an ed25519 key at {}", private.display()); } }
add(AddArgs { key: Some(public.display().to_string()), name: args.name, dry_run: args.dry_run, json: args.json, }) .await?;
if !args.json { println!( "next: `atgc repo configure` in the worktree that will push, \ to pin this key there" ); } Ok(())}
pub(in crate::cmd) async fn add(args: AddArgs) -> Result<()> { crate::term::jsonout::init(args.json); let selection = account::select().await?; selection.announce(); let who = selection.display();
let source = resolve_source(args.key.as_deref())?; let comparable = ssh::normalize(&source.line) .with_context(|| format!("{} is not an SSH public key", source.describe()))?; let name = args .name .or_else(|| comment_of(&source.line)) .or_else(|| source.stem()) .unwrap_or_else(|| "atgc".to_string());
let pds = crate::clients::atproto::did::pds_or_fail(&selection.did).await?; let registered = ssh::registered_keys(&pds, &selection.did).await?;
let mut report = AddedJson { dry_run: args.dry_run, uri: None, added: true, existing_rkey: None, name: name.clone(), kind: comparable .split_whitespace() .next() .unwrap_or_default() .to_string(), fingerprint: ssh::fingerprint(&comparable), source: source.describe(), };
if !args.json { println!( "key: {} {}", comparable.split_whitespace().next().unwrap_or("?"), ssh::fingerprint(&comparable).unwrap_or_default() ); println!("from: {}", source.describe()); println!("name: {name}"); }
// Adding a key twice is not an error worth failing on: the second run is // usually somebody making sure, and the PDS would happily hold two // records for one key with nothing to choose between them. if let Some(hit) = registered.iter().find(|r| r.key == comparable) { if args.json { report.added = false; report.existing_rkey = Some(hit.rkey.clone()); return crate::term::jsonout::emit(&report); } println!( "already registered for {who} as {:?} (rkey {})", hit.name.as_deref().unwrap_or("(unnamed)"), hit.rkey ); return Ok(()); }
let record = PublicKey::new() .key(source.line.clone()) .name(name) .created_at(Datetime::now()) .build(); // Before the dry-run return: a key record needs nothing minted // elsewhere, so a dry run has no excuse to say "would work" for one the // lexicon refuses. A refusal rather than a note, unlike the scope check // below, because it is a fact about what the caller passed. record .validate() .map_err(|e| anyhow!("{e}")) .context("key record would be refused by the lexicon")?;
// A dry run says what would happen, and a session too old to make this // write is part of what would happen — but it must not turn a dry run // into a failure, so the same check is a note here and a refusal below. if args.dry_run { if let Err(e) = scope_check(&selection) { crate::term::say::warning!(Auth, "would fail: {e}"); } if args.json { return crate::term::jsonout::emit(&report); } println!("dry run; nothing written"); return Ok(()); } scope_check(&selection)?;
let agent = auth::agent_for_did(&selection.did).await?; crate::logging::debug::log(format!( ">> createRecord {}\n{}", crate::lexicon::tangled::PUBLIC_KEY_NSID, crate::logging::debug::pretty(&record) )); let output = agent.create_record(record, None).await.map_err(|e| { crate::logging::debug::dump_err("createRecord error", &e); anyhow!("writing the key record failed: {e}") })?; // The record is the source of truth, but a knot only learns about it when // the appview has seen it go by on the firehose and told the knot. That // is usually seconds and is not instant, and a push tried in between is // refused exactly as it was before the key existed. const LAG: &str = "knots pick this up from the firehose; a push may need a moment"; if args.json { report.uri = Some(output.uri.to_string()); crate::term::jsonout::emit(&report)?; crate::term::say::note!(Index, "{LAG}"); return Ok(()); } println!("added {}", output.uri); crate::term::say::note!(Index, "{LAG}"); Ok(())}
/// Withdraw a key. Pushes authenticated by it stop being accepted.pub(in crate::cmd) async fn delete(args: DeleteArgs) -> Result<()> { crate::term::jsonout::init(args.json); let selection = account::select().await?; selection.announce();
let pds = crate::clients::atproto::did::pds_or_fail(&selection.did).await?; let registered = ssh::registered_keys(&pds, &selection.did).await?; if registered.is_empty() { return Err(crate::exit::fail( crate::exit::Exit::NotFound, format!("{} has no registered SSH keys", selection.display()), )); }
// A selector that is a key line or a readable file is compared as a key; // anything else is matched against the record's own fields. Best-effort // on purpose: a name that happens to look like a path is still a name. let as_key = KeySource::read(&args.key) .ok() .and_then(|s| ssh::normalize(&s.line)); let hits = matching(®istered, &args.key, as_key.as_deref());
let key = match hits.as_slice() { [one] => *one, // A selector that matched nothing is an identifier that resolved to // nothing; one that matched several is a command line that names two // records at once. Different next moves, so different statuses. [] => { return Err(crate::exit::fail( crate::exit::Exit::NotFound, format!( "no registered key matches {:?}; there {} on file\n\ select one by record key, name, fingerprint or path (atgc key list)", args.key, plural(registered.len()) ), )); } many => { return Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "{:?} matches {} keys ({}); name one by its record key", args.key, many.len(), many.iter() .map(|k| k.rkey.as_str()) .collect::<Vec<_>>() .join(", ") ), )); } };
let mut report = DeletedJson { dry_run: args.dry_run, deleted: false, rkey: key.rkey.clone(), name: key.name.clone(), kind: key.kind().to_string(), fingerprint: ssh::fingerprint(&key.key), was_last: registered.len() == 1, };
if !args.json { println!("key: {} {}", key.kind(), key.fingerprint()); println!("name: {}", key.name.as_deref().unwrap_or("(unnamed)")); println!("rkey: {}", key.rkey); if report.was_last { println!( "note: this is the only key on the account; nothing will be able to push as it" ); } }
if args.dry_run { if let Err(e) = scope_check(&selection) { crate::term::say::warning!(Auth, "would fail: {e}"); } if args.json { return crate::term::jsonout::emit(&report); } println!("dry run; nothing deleted"); return Ok(()); } scope_check(&selection)?;
let rkey = RecordKey::any_owned(&key.rkey) .map_err(|e| anyhow!("{} is not a usable record key: {e}", key.rkey))?; let agent = auth::agent_for_did(&selection.did).await?; crate::logging::debug::log(format!( ">> deleteRecord {} rkey={}", crate::lexicon::tangled::PUBLIC_KEY_NSID, key.rkey )); agent .delete_record::<PublicKeyRecord>(rkey) .await .map_err(|e| { crate::logging::debug::dump_err("deleteRecord error", &e); anyhow!("deleting the key record failed: {e}") })?; if args.json { report.deleted = true; return crate::term::jsonout::emit(&report); } println!("deleted {}", key.rkey); Ok(())}
/// Whether this account's login can write a key record at all.////// Both writes here are on a collection atgc has only recently started asking/// for a scope on, so every session made before that, which on any machine/// with accounts on it is *all* of them: would be refused by the PDS with a/// 403 that reads as a permissions problem. This is the check that names the/// login instead. See [`crate::clients::tangled::scope::require_scope`].fn scope_check(selection: &account::Selection) -> Result<()> { crate::clients::tangled::scope::require_scope( crate::cmd::acting_scope(&selection.did).as_deref(), selection.handle.as_deref(), crate::lexicon::tangled::PUBLIC_KEY_NSID, )}
/// The registered keys a selector picks out.////// Four ways to name a key, because there are four things a person has in/// front of them: the record key from a listing, the name they gave it, the/// fingerprint their ssh tools print, and the key file itself. A fingerprint/// may be given with or without its `SHA256:` prefix, since `ssh-keygen -l`/// prints one and some UIs print the other.////// Returns every match rather than the first: two keys can share a name, and/// deleting whichever one happened to sort first is not a thing to do/// silently.fn matching<'a>( keys: &'a [Registered], selector: &str, as_key: Option<&str>,) -> Vec<&'a Registered> { let selector = selector.trim(); let bare = selector.strip_prefix("SHA256:").unwrap_or(selector); keys.iter() .filter(|k| { k.rkey == selector || as_key.is_some_and(|key| k.key == key) || k.fingerprint().strip_prefix("SHA256:") == Some(bare) || k.name .as_deref() .is_some_and(|n| n.eq_ignore_ascii_case(selector)) }) .collect()}
/// A public key and where it was read from.#[derive(Debug)]struct KeySource { line: String, path: Option<PathBuf>,}
impl KeySource { /// A key given as an argument: a path to a `.pub` file, a path to the /// private half beside one, or the key line itself. fn read(input: &str) -> Result<KeySource> { let input = input.trim(); if ssh::normalize(input).is_some() { return Ok(KeySource { line: input.to_string(), path: None, }); }
let path = expand_home(input); // `key add ~/.ssh/id_ed25519` is what people type, and the // private half is emphatically not what should be published — so // reach for the `.pub` beside it rather than reading it. let pub_path = PathBuf::from(format!("{}.pub", path.display())); let path = if !path.exists() || (pub_path.exists() && !is_public_key_file(&path)) { pub_path } else { path }; let contents = std::fs::read_to_string(&path) .with_context(|| format!("cannot read a public key from {}", path.display()))?; // The check that has to happen before anything is sent anywhere: a // private key pasted or pointed at must never become a public record. if contents.contains("PRIVATE KEY") { bail!( "{} is a private key: publish the public half instead ({}.pub)", path.display(), path.display() ); } Ok(KeySource { line: contents.trim().to_string(), path: Some(path), }) }
fn describe(&self) -> String { match &self.path { Some(path) => path.display().to_string(), None => "the key given on the command line".to_string(), } }
/// `id_ed25519.pub` -> `id_ed25519`, as a last-resort name. fn stem(&self) -> Option<String> { Some( self.path .as_ref()? .file_stem()? .to_string_lossy() .into_owned(), ) }}
/// Which key to publish when none was named: the one in `~/.ssh`, if there is/// exactly one.////// Refuses to choose between several. Which key an account pushes with is not/// a thing to guess at: the wrong guess publishes a key the user did not/// mean to publish, and a public key record is not private information but it/// is a statement about which machines are theirs.fn resolve_source(input: Option<&str>) -> Result<KeySource> { if let Some(input) = input { return KeySource::read(input); } let local = ssh::local_keys(); match local.as_slice() { [(path, _)] => KeySource::read(&path.to_string_lossy()), // Both `Usage`, for the reason `exit.rs` gives for the largest // family of `2`s: the argument nobody types. `atgc key add` with no // path means "the key in ~/.ssh", and a machine with none — or with // several — is one where that command line does not name anything. // Neither is `NotFound`, which is about an identifier the caller // supplied; here there was no identifier, which is the problem. [] => Err(crate::exit::fail( crate::exit::Exit::Usage, "no public key found in ~/.ssh: generate one with \ `ssh-keygen -t ed25519 -C \"$(hostname)\"`, then add it", )), many => Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "several keys in ~/.ssh; name the one to publish:\n{}", many.iter() .map(|(p, _)| format!(" atgc key add {}", p.display())) .collect::<Vec<_>>() .join("\n") ), )), }}
/// The comment a key carries, which is what OpenSSH puts there to say whose/// machine it is: the best default name available for the record.fn comment_of(line: &str) -> Option<String> { let comment = line .split_whitespace() .skip(2) .collect::<Vec<_>>() .join(" "); (!comment.is_empty()).then_some(comment)}
/// Whether a file holds a public key, for telling `id_ed25519` from/// `id_ed25519.pub` when the path given has no extension.fn is_public_key_file(path: &Path) -> bool { std::fs::read_to_string(path).is_ok_and(|c| ssh::normalize(&c).is_some())}
/// `~/` at the front of a path, which a shell would have expanded and a/// quoted argument or a config value has not.fn expand_home(input: &str) -> PathBuf { match input.strip_prefix("~/") { Some(rest) => match std::env::var_os("HOME") { Some(home) => PathBuf::from(home).join(rest), None => PathBuf::from(input), }, None => PathBuf::from(input), }}
fn plural(n: usize) -> String { match n { 1 => "is 1 key".to_string(), n => format!("are {n} keys"), }}
#[cfg(test)]mod tests { use super::{KeySource, comment_of, key_json, matching, resolve_source}; use crate::clients::git::ssh::{self, Registered}; use crate::testutil::TempKeys;
fn registered(lines: &[(&str, &str)]) -> Vec<Registered> { lines .iter() .enumerate() .map(|(i, (name, line))| Registered { rkey: format!("3msg7wllcqc{i}"), key: ssh::normalize(line).expect("a key line"), name: Some(name.to_string()), created_at: Some("2026-08-07T12:00:00Z".to_string()), }) .collect() }
/// The listing's JSON row, against a real generated key: the fingerprint /// is the one `ssh-keygen -lf` prints, and `on_this_machine` carries the /// path when the local scan holds the same key: the one thing this /// listing can say that Tangled's web UI cannot. #[test] fn a_key_row_carries_the_real_fingerprint_and_where_it_lives() { let keys = TempKeys::new("json-row"); let registered = registered(&[("laptop", &keys.public_line())]); let local = vec![(keys.public(), ssh::normalize(&keys.public_line()).unwrap())];
let row = key_json(®istered[0], &local); assert_eq!(row.name.as_deref(), Some("laptop")); assert_eq!(row.kind, "ssh-ed25519"); assert_eq!( row.fingerprint.as_deref(), Some(keys.fingerprint().as_str()) ); assert_eq!( row.on_this_machine.as_deref(), Some(keys.public().to_string_lossy().as_ref()) ); // The comparable form, not the record's raw line: Tangled's own web // UI appends a comment of its own, and this field is the one that // compares equal to a local copy. assert!(!row.key.contains("atgc-test-"));
// A machine that does not hold the key says so with a null rather // than by omitting the field. let elsewhere = key_json(®istered[0], &[]); assert_eq!(elsewhere.on_this_machine, None); let value = serde_json::to_value(&elsewhere).unwrap(); assert!(value.get("on_this_machine").is_some()); assert_eq!(value["on_this_machine"], serde_json::Value::Null); }
/// A real `.pub` file, read the way a person points at one. The comment /// `ssh-keygen -C` wrote becomes the record's name, which is the whole /// reason the default is the comment and not the filename: it already /// says which machine the key is on. #[test] fn reads_a_generated_public_key_off_disk() { let keys = TempKeys::new("read"); let source = KeySource::read(&keys.public().to_string_lossy()).expect("a readable key"); assert_eq!(source.line, keys.public_line()); assert!(comment_of(&source.line).unwrap().starts_with("atgc-test-")); }
/// `atgc key add ~/.ssh/id_ed25519` is what people type, and it names /// the private half. Publishing that would hand the key material to the /// PDS, so the path is redirected to the `.pub` beside it. #[test] fn a_private_key_path_publishes_the_public_half() { let keys = TempKeys::new("private-path"); let source = KeySource::read(&keys.private().to_string_lossy()).expect("the public half"); assert_eq!(source.line, keys.public_line()); assert!(!source.line.contains("PRIVATE")); }
/// And when there is no public half to redirect to, it refuses rather /// than uploading what it was handed. This is the one failure in this /// module that would be unrecoverable: a private key written to a public /// record cannot be unpublished, only rotated. #[test] fn refuses_to_publish_a_private_key() { let keys = TempKeys::new("private-only"); std::fs::remove_file(keys.public()).unwrap(); let err = KeySource::read(&keys.private().to_string_lossy()) .expect_err("a private key must not be publishable") .to_string(); assert!(err.contains("is a private key"), "{err}"); }
/// The key can also be given inline, as it comes off a clipboard. #[test] fn takes_a_key_line_as_written() { let keys = TempKeys::new("inline"); let source = KeySource::read(&format!(" {} ", keys.public_line())).expect("a key line"); assert_eq!(source.line, keys.public_line()); assert!(source.path.is_none()); }
/// Anything that is neither a key nor a file is a failure, and the /// message has to name what was not found rather than the shape it /// wanted. #[test] fn says_which_path_it_could_not_read() { let err = KeySource::read("/nonexistent/atgc/key.pub") .expect_err("no such file") .to_string(); assert!(err.contains("/nonexistent/atgc/key.pub"), "{err}"); // An explicit argument is never second-guessed into a `~/.ssh` scan. assert!(resolve_source(Some("not-a-key")).is_err()); }
/// Four ways to name a key, because a person has four things in front of /// them: a listing's record key, the name they typed, the fingerprint /// their ssh tools print, and the file itself. #[test] fn a_key_can_be_named_four_ways() { let mine = TempKeys::new("select-mine"); let theirs = TempKeys::new("select-theirs"); let keys = registered(&[ ("laptop", &mine.public_line()), ("desktop", &theirs.public_line()), ]); let by = |sel: &str, as_key: Option<&str>| { matching(&keys, sel, as_key) .iter() .map(|k| k.rkey.clone()) .collect::<Vec<_>>() };
assert_eq!(by("3msg7wllcqc0", None), ["3msg7wllcqc0"]); assert_eq!(by("laptop", None), ["3msg7wllcqc0"]); assert_eq!(by("LAPTOP", None), ["3msg7wllcqc0"]); assert_eq!(by(&mine.fingerprint(), None), ["3msg7wllcqc0"]); // Without the SHA256: prefix, which is how some UIs print it. let bare = mine.fingerprint().trim_start_matches("SHA256:").to_string(); assert_eq!(by(&bare, None), ["3msg7wllcqc0"]); // And by the key itself, which is what a path resolves to. let as_key = ssh::normalize(&theirs.public_line()).unwrap(); assert_eq!( by(&theirs.public().to_string_lossy(), Some(&as_key)), ["3msg7wllcqc1"] ); }
/// A selector matching nothing, and one matching two records, are both /// refusals, and they are different refusals. Deleting whichever of two /// same-named keys sorted first is exactly the silent wrong answer this /// returns a list to avoid. #[test] fn an_ambiguous_selector_matches_every_key_it_could_mean() { let mine = TempKeys::new("ambiguous-mine"); let theirs = TempKeys::new("ambiguous-theirs"); let keys = registered(&[ ("laptop", &mine.public_line()), ("laptop", &theirs.public_line()), ]);
assert_eq!(matching(&keys, "laptop", None).len(), 2); assert!(matching(&keys, "nothing-like-this", None).is_empty()); // The record key still separates them, which is what the error says // to use. assert_eq!(matching(&keys, "3msg7wllcqc1", None).len(), 1); }
/// The 4096-char limit on `key` is the lexicon's, read through generated /// `validate()` rather than hand-checked: nothing in the old /// hand-written struct enforced this at all, so it's newly caught here /// rather than surfacing as a PDS 400 on `key add`. #[test] fn a_key_line_over_the_lexicons_limit_is_refused() { use super::PublicKey; use jacquard::types::string::Datetime; use tangled_lexicon::LexiconSchema;
let record = PublicKey::new() .key("x".repeat(4097)) .name("laptop") .created_at(Datetime::now()) .build(); assert!(record.validate().is_err());
let record = PublicKey::new() .key("x".repeat(4096)) .name("laptop") .created_at(Datetime::now()) .build(); assert!(record.validate().is_ok()); }}