Files
platform/src/servers/os-user-ssh.ts
T
pastilhasandClaude Opus 5 0fb9a29e64 ssh for a member's linux account, both directions
Inbound and outbound are two keys doing two jobs, and treating them as
alternatives breaks the goal:

  inbound   ~/.ssh/authorized_keys, from an optional public key the owner pastes
            on the create form. Their private half stays on their laptop.
  outbound  ~/.ssh/id_ed25519, generated in their home, never leaves the machine.

"They pasted a key, so skip generating one" is the obvious simplification. Agent
forwarding covers a human in an interactive session, but 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. The inbound key is therefore optional and the
outbound one is not.

No linux password, ever: useradd sets none, which blocks password login and does
not block key auth. So "real user, reachable over SSH, no password anywhere" is
the resting state, and the platform password stays the platform's business.

Validation is about line count, not key shape. Every line of authorized_keys is a
credential, so a pasted value with a newline would install a SECOND key silently.
Multi-line refused, a private key refused by name, an options prefix refused.

Every write goes through sudo install: the home is 700 and the member's, so the
service user cannot even create .ssh. install sets content, owner and mode in one
step, and content travels as a temp path so nothing quotes a form value into a
shell. ssh-keygen runs AS the member so the private key is never briefly root's.

known_hosts is not seeded — StrictHostKeyChecking accept-new instead. The Gitea
SSH endpoint is not knowable at create time, and the default setting makes a first
connection prompt, which in a non-interactive agent turn is a hang rather than an
error. accept-new still refuses a changed host key.

The generated public key is stored on the row and shown twice: on the after-create
panel and behind a key button on the user's row. It has an errand attached that
nothing else will remind anyone about — it must be added to their Gitea account.

Verified with a real useradd: .ssh 700 and id_ed25519 600 both owned by the member
and usable by them, authorized_keys byte-identical to the paste, no key rotation on
a second run, and a multi-line paste refused with authorized_keys untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:02:33 +00:00

172 lines
7.6 KiB
TypeScript

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<SudoResult> {
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<SudoResult> {
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<SshProvisionResult> {
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 };
}