import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { osUserHome, runAs } from './os-user'; // SSH for a member's Linux account: how they reach the machine, and how the machine reaches Gitea as them. // // ── Two keys, two directions, and they are not alternatives ── // // inbound `authorized_keys` holds a public key the OWNER pasted at create time. Their private half // stays on their laptop. Optional: an account with none is platform-only, which is a fine // state, just not a shell-from-anywhere one. // outbound `id_ed25519` is generated HERE, in their home, and never leaves. This is what pushes to // Gitea. // // The distinction matters because "he pasted his key, so we can skip generating one" is the obvious // simplification and it breaks the actual goal. Agent forwarding covers a human in an interactive SSH // session; a platform-spawned agent has no agent socket to borrow, so an edge checkout it is asked to // commit and push needs a key that lives on the box. // // ── Why every write goes through `sudo install` ── // // The home is 700 and owned by the member, so the service user cannot write into it at all — not even to // create `.ssh`. `install` sets content, owner and mode in ONE step, which also closes the window where a // key file exists at the process umask before a chmod lands. And passing file content as a path rather // than as shell text means nothing here has to reason about quoting a value that came from a form. /** * Public key formats OpenSSH accepts, anchored and single-line. * * Validated because this string is appended to `authorized_keys`, where each line is a credential. A * value with an embedded newline would inject a SECOND authorized key — so the check that matters is not * "does this look like a key" but "is this exactly one line". */ const PUBLIC_KEY_RE = /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com) [A-Za-z0-9+/]+={0,3}(\s+\S.*)?$/; export function validatePublicKey(raw: string): { ok: true; key: string } | { ok: false; error: string } { const key = raw.trim(); if (!key) return { ok: false, error: 'empty' }; // Checked before the pattern so the message is about the real problem: a pasted `id_ed25519` (private) // or a multi-key blob are both things people actually do. if (/[\r\n]/.test(key)) return { ok: false, error: 'a public key must be a single line' }; if (key.includes('PRIVATE KEY')) return { ok: false, error: 'that is a PRIVATE key — paste the .pub file' }; if (!PUBLIC_KEY_RE.test(key)) { return { ok: false, error: 'not an OpenSSH public key (expected e.g. "ssh-ed25519 AAAA… comment")' }; } return { ok: true, key }; } type SudoResult = { ok: boolean; out: string }; async function sudo(args: string[]): Promise { const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() }; } /** Write `content` into the member's tree with the right owner and mode, via a temp file. */ async function installFile(params: { content: string; dest: string; uid: number; gid: number; mode: string; }): Promise { const dir = await mkdtemp(join(tmpdir(), 'officer-ssh-')); const staged = join(dir, 'staged'); try { await writeFile(staged, params.content, { mode: 0o600 }); return await sudo([ 'install', '-o', String(params.uid), '-g', String(params.gid), '-m', params.mode, staged, params.dest, ]); } finally { await rm(dir, { recursive: true, force: true }); } } export type SshProvisionResult = | { ok: true; publicKey: string; generated: boolean; inboundKeyInstalled: boolean } | { ok: false; error: string }; /** * Give the account a working `~/.ssh`: inbound key if one was supplied, and an outbound keypair either way. * * Idempotent. An existing `id_ed25519` is kept and its public half returned rather than regenerated — * rotating a key silently would break every Gitea account and deploy key it had been added to. */ export async function provisionSshAccess(params: { email: string; osUser: string; uid: number; gid: number; /** The owner-supplied public key for inbound SSH. Absent or empty means no inbound access. */ authorizedKey?: string | null; }): Promise { const home = osUserHome(params.email); const sshDir = join(home, '.ssh'); const keyPath = join(sshDir, 'id_ed25519'); // `install -d` creates the directory with the owner and mode in one call. sshd refuses to use a .ssh // that is group- or world-writable, so 700 is a requirement rather than caution. const dir = await sudo(['install', '-d', '-o', String(params.uid), '-g', String(params.gid), '-m', '700', sshDir]); if (!dir.ok) return { ok: false, error: `could not create ${sshDir}: ${dir.out}` }; let inboundKeyInstalled = false; if (params.authorizedKey?.trim()) { const checked = validatePublicKey(params.authorizedKey); if (!checked.ok) return { ok: false, error: `public key rejected: ${checked.error}` }; const written = await installFile({ content: `${checked.key}\n`, dest: join(sshDir, 'authorized_keys'), uid: params.uid, gid: params.gid, mode: '600', }); if (!written.ok) return { ok: false, error: `could not write authorized_keys: ${written.out}` }; inboundKeyInstalled = true; } // `accept-new` rather than seeding known_hosts with ssh-keyscan. We do not know the Gitea SSH host at // account-creation time — the platform stores an HTTP base URL, and the SSH endpoint may be a different // host or port entirely. The failure this prevents is specific and nasty: default StrictHostKeyChecking // makes a first connection PROMPT, and a prompt in a non-interactive agent turn is a hang, not an error. // `accept-new` trusts on first use and still refuses a CHANGED key, which is the attack that matters. const config = await installFile({ content: ['Host *', ' StrictHostKeyChecking accept-new', ' IdentityFile ~/.ssh/id_ed25519', ''].join('\n'), dest: join(sshDir, 'config'), uid: params.uid, gid: params.gid, mode: '600', }); if (!config.ok) return { ok: false, error: `could not write ssh config: ${config.out}` }; // Checked with sudo: the service user cannot stat inside a 700 home. It matters that this is checked // rather than attempted — `ssh-keygen` on an existing path PROMPTS to overwrite, and that prompt in a // spawned process is a hang. const exists = await sudo(['test', '-f', keyPath]); let generated = false; if (!exists.ok) { // Generated AS the member so the files are theirs from the moment they exist; a private key that is // briefly root-owned is a private key that can be left root-owned by a failure halfway through. const proc = runAs(params.osUser, [ 'ssh-keygen', '-t', 'ed25519', '-N', '', '-C', `${params.osUser}@officer`, '-f', keyPath, ]); const err = await new Response(proc.stderr).text(); if ((await proc.exited) !== 0) return { ok: false, error: `ssh-keygen failed: ${err.trim()}` }; generated = true; } const pub = await sudo(['cat', `${keyPath}.pub`]); if (!pub.ok) return { ok: false, error: `could not read the generated public key: ${pub.out}` }; return { ok: true, publicKey: pub.out.trim(), generated, inboundKeyInstalled }; }