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>
This commit is contained in:
2026-08-11 17:02:33 +00:00
co-authored by Claude Opus 5
parent 5c7ceb2283
commit 0fb9a29e64
8 changed files with 484 additions and 12 deletions
+51
View File
@@ -235,6 +235,57 @@ shell is. So:
Say "cannot see behind it", not "cannot leave it". Say "cannot see behind it", not "cannot leave it".
## SSH: two keys, two directions
A member is meant to behave like a real user on the machine — reachable over SSH, able to push to Gitea as
themselves, able to have an agent do the same on their behalf. That needs two keys, and they are **not**
alternatives:
| | where | who holds the private half | what it is for |
| --- | --- | --- | --- |
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | *they* SSH into this machine |
| **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | *the machine* authenticates to Gitea as them |
The tempting simplification is "if they pasted a key, skip generating one." It breaks the actual goal.
Agent forwarding covers a human in an interactive 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. So the
inbound key is optional — an account without one is simply platform-only — and the outbound keypair is
generated regardless.
**No Linux password, ever.** `useradd` is called with none, which leaves `!` in shadow. That blocks
*password* login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere"
is the resting state. The privilege drop is `sudo -n setpriv` performed by the platform, so there is nothing
to authenticate. Keeping the platform password and the machine out of each other's business is the point: a
Linux password would be a second door that changing the platform password does not close and deleting the
platform account does not lock.
**Validation is about line count, not key shape.** Every line of `authorized_keys` is a credential, so a
pasted value containing a newline would silently install a *second* authorized key. `validatePublicKey`
refuses anything multi-line, refuses a private key with a message saying so, and refuses an options prefix
(`command="…" ssh-ed25519 …`) — legitimate OpenSSH, but not something anyone pastes by accident, and it can
force a command.
**Everything is written with `sudo install`.** The home is 700 and owned by the member, so the service user
cannot create `.ssh` at all. `install` sets content, owner and mode in one step, which also closes the
window where a key file briefly exists at the process umask. File content goes via a temp path rather than
shell text, so nothing has to reason about quoting a value that came from a form.
**`StrictHostKeyChecking accept-new`, not a seeded `known_hosts`.** The Gitea SSH endpoint is not knowable
at account-creation time — the platform stores an HTTP base URL, and SSH may be a different host or port.
The failure this avoids is specific: the default setting 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* host key, which is the attack that matters.
**The generated public key is stored on the user row** (`users.os_ssh_public_key`) and shown after creation
and on the user's row afterwards. It is public by definition, and it has an errand attached that nothing
else will remind anyone about: it has to be added to that person's Gitea account or their pushes fail with
a permission error that says nothing about a missing key.
Verified end to end with a real `useradd`: `.ssh` 700 and `id_ed25519` 600 both owned by the member and
readable by them, `authorized_keys` byte-identical to what was pasted, the key **not** rotated on a second
run (it has been added to Gitea by then), and a multi-line paste refused with `authorized_keys` left
untouched.
## Follow-ups this creates ## Follow-ups this creates
- **Deleting a member no longer removes their home.** It belongs to their uid, so the platform cannot - **Deleting a member no longer removes their home.** It belongs to their uid, so the platform cannot
@@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres. // The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
@@ -45,7 +46,22 @@ function generatePassword(): string {
return chars.join(''); return chars.join('');
} }
const EMPTY = { email: '', name: '', username: '', password: '', role: 'Member' }; const EMPTY = { email: '', name: '', username: '', password: '', role: 'Member', sshPublicKey: '' };
/**
* What the server did, shown after the fact.
*
* Kept on screen rather than announced in a toast because two of these values are only obtainable now: the
* password is stored as an argon2 hash, and the generated public key sits in a 700 home. A toast that
* carries something unrecoverable is a toast that gets dismissed by a stray click.
*/
type CreatedAccount = {
email: string;
password: string;
osUser: string | null;
osSshPublicKey: string | null;
osUserError: string | null;
};
export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => { export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
const client = useClient(); const client = useClient();
@@ -53,28 +69,32 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form, setForm] = useState(EMPTY); const [form, setForm] = useState(EMPTY);
const [created, setCreated] = useState<CreatedAccount | null>(null);
const set = (key: keyof typeof EMPTY) => (value: string) => setForm((prev) => ({ ...prev, [key]: value })); const set = (key: keyof typeof EMPTY) => (value: string) => setForm((prev) => ({ ...prev, [key]: value }));
const close = () => { const close = () => {
setOpen(false); setOpen(false);
setForm(EMPTY); setForm(EMPTY);
setCreated(null);
}; };
const submit = async (ev: React.FormEvent) => { const submit = async (ev: React.FormEvent) => {
ev.preventDefault(); ev.preventDefault();
setSaving(true); setSaving(true);
try { try {
await client.post('/users', form); const res = await client.post<{
user: { osUser: string | null; osSshPublicKey: string | null };
osUserError: string | null;
}>('/users', form);
await queryClient.invalidateQueries({ queryKey: usersKey }); await queryClient.invalidateQueries({ queryKey: usersKey });
// The password is named in the toast on purpose. It is the only moment it is recoverable — the setCreated({
// server stores an argon2 hash and there is no reset flow yet, so an owner who closed the form email: form.email,
// without noting it would have to delete the account and make it again. password: form.password,
toast.success(`${form.email} created`, { osUser: res.user.osUser,
description: `Password: ${form.password}`, osSshPublicKey: res.user.osSshPublicKey,
duration: 30_000, osUserError: res.osUserError,
}); });
close();
} catch (ex) { } catch (ex) {
// The server's message is the useful one here — which field, and why. // The server's message is the useful one here — which field, and why.
toast.error(ex instanceof Error ? ex.message : 'Could not create the account'); toast.error(ex instanceof Error ? ex.message : 'Could not create the account');
@@ -83,6 +103,83 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
} }
}; };
const copy = (value: string, what: string) => {
void navigator.clipboard.writeText(value);
toast.success(`${what} copied`);
};
// ── After creation ──
//
// Deliberately a wall you have to dismiss. Both values below are unrecoverable once this closes, and the
// public key has a job attached to it that nothing else will remind you to do.
if (created) {
return (
<div className="space-y-4 rounded-lg border border-duck-teal/40 bg-duck-teal/5 p-4">
<div>
<h3 className="text-sm font-medium">{created.email} created</h3>
<p className="text-xs text-muted-foreground">
Copy what you need before closing none of it can be shown again.
</p>
</div>
<div className="space-y-1.5">
<Label>Password</Label>
<div className="flex gap-2">
<Input readOnly value={created.password} className="font-mono" />
<Button type="button" variant="outline" size="icon" onClick={() => copy(created.password, 'Password')}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Stored as a hash this is the only time it exists in readable form. They can change it from their own
profile once signed in.
</p>
</div>
{created.osUser && (
<div className="space-y-1.5">
<Label>Linux account</Label>
<Input readOnly value={created.osUser} className="font-mono" />
</div>
)}
{created.osSshPublicKey && (
<div className="space-y-1.5">
<Label>Their SSH public key</Label>
<div className="flex gap-2">
<Textarea readOnly value={created.osSshPublicKey} rows={3} className="font-mono text-xs" />
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copy(created.osSshPublicKey!, 'Public key')}
>
<Copy className="h-4 w-4" />
</Button>
</div>
{/* The one action this screen cannot do for you. Without it their pushes fail with a
permission error that says nothing about a missing key. */}
<p className="text-xs text-muted-foreground">
Generated on the machine; the private half never leaves it.{' '}
<strong>Add this to their Gitea account</strong> so they can push. Retrievable later from their row.
</p>
</div>
)}
{created.osUserError && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
<div className="font-medium">The account works, but its Linux side did not finish</div>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{created.osUserError}</p>
</div>
)}
<Button type="button" onClick={close}>
Done
</Button>
</div>
);
}
if (!open) { if (!open) {
return ( return (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}> <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
@@ -198,6 +295,27 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
At least 12 characters, with upper and lower case, a number and a symbol. At least 12 characters, with upper and lower case, a number and a symbol.
</p> </p>
</div> </div>
{/* Inbound only, and optional. The OUTBOUND key is generated either way — pasting one here does
not replace it, because a key on a laptop is no use to an agent running on the server. */}
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-ssh">
Their SSH public key <span className="ml-1 text-xs opacity-60">(optional)</span>
</Label>
<Textarea
id="new-user-ssh"
rows={3}
spellCheck={false}
placeholder="ssh-ed25519 AAAAC3Nza… ana@laptop"
className="font-mono text-xs"
value={form.sshPublicKey}
onChange={(ev) => set('sshPublicKey')(ev.target.value)}
/>
<p className="text-xs text-muted-foreground">
Lets them SSH into this machine as their own Linux user. Leave empty for platform-only access either way
they get a keypair of their own for pushing to Gitea, and you will be shown its public half next.
</p>
</div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Crown, Trash2, Loader2 } from 'lucide-react'; import { Crown, Trash2, Loader2, KeyRound } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -26,6 +26,8 @@ type ManagedUser = {
role: string; role: string;
createdAt: string; createdAt: string;
isOwner: boolean; isOwner: boolean;
osUser: string | null;
osSshPublicKey: string | null;
}; };
type UsersResponse = { type UsersResponse = {
@@ -112,6 +114,9 @@ export const UsersSection = () => {
<div className="truncate text-xs text-muted-foreground"> <div className="truncate text-xs text-muted-foreground">
{user.email} {user.email}
{user.status !== 'Active' && ` · ${user.status}`} {user.status !== 'Active' && ` · ${user.status}`}
{/* Shown because "does this person have a Linux account" is otherwise invisible, and it
decides whether their terminal and agent run as them or not at all. */}
{user.osUser && ` · ${user.osUser}`}
</div> </div>
</div> </div>
@@ -134,6 +139,24 @@ export const UsersSection = () => {
</SelectContent> </SelectContent>
</Select> </Select>
{/* The errand the create screen promised would still be here: this key has to end up on
their Gitea account, and nothing else will remind anyone. */}
{user.osSshPublicKey && (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
aria-label={`Copy ${user.email}'s SSH public key`}
title="Copy their SSH public key (add it to their Gitea account)"
onClick={() => {
void navigator.clipboard.writeText(user.osSshPublicKey!);
toast.success('Public key copied');
}}
>
<KeyRound className="h-4 w-4" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -47,6 +47,14 @@ export const users = pgTable(
* See docs/per-user-linux-accounts.md. * See docs/per-user-linux-accounts.md.
*/ */
osUser: text('os_user').unique(), osUser: text('os_user').unique(),
/**
* The PUBLIC half of the outbound SSH key generated in this account's home.
*
* Stored so the owner can retrieve it later — it has to be pasted into the member's Gitea account, and
* the home is 700 so nothing can read it back off disk without root. Public by definition; the private
* half never leaves the machine and is never in this database.
*/
osSshPublicKey: text('os_ssh_public_key'),
avatar: text('avatar'), avatar: text('avatar'),
passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }), passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+36 -2
View File
@@ -5,6 +5,7 @@ import argon2 from 'argon2';
import * as errors from '@@/custom-errors'; import * as errors from '@@/custom-errors';
import { provisionUserDirs } from '@@/data-path'; import { provisionUserDirs } from '@@/data-path';
import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user'; import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user';
import { provisionSshAccess, validatePublicKey } from '@@/os-user-ssh';
import { validatePassword } from '../auth/validate-password'; import { validatePassword } from '../auth/validate-password';
import { validateUsername } from '../auth/validate-username'; import { validateUsername } from '../auth/validate-username';
import { toPublicUser } from './manage-users'; import { toPublicUser } from './manage-users';
@@ -62,6 +63,19 @@ export const createUserHandler: Handler = async function (ctx) {
); );
} }
// The inbound SSH key, if the owner supplied one. Validated HERE rather than at use, so a bad paste is a
// 400 on the form instead of an account that exists with a confusing warning attached.
//
// Optional by design: an account with no inbound key is platform-only, which is a perfectly good state.
// The OUTBOUND key is generated regardless — see os-user-ssh.ts for why those are not alternatives.
const rawKey = typeof body.sshPublicKey === 'string' ? body.sshPublicKey.trim() : '';
let inboundKey: string | null = null;
if (rawKey) {
const checked = validatePublicKey(rawKey);
if (!checked.ok) throw errors.BAD_REQUEST(`SSH public key: ${checked.error}`);
inboundKey = checked.key;
}
// Checked before the insert purely for the message — both columns are unique, so the database is the // Checked before the insert purely for the message — both columns are unique, so the database is the
// real guard and this is a race it can lose harmlessly (the insert then throws). // real guard and this is a race it can lose harmlessly (the insert then throws).
if (await getUserByEmail(email)) throw errors.CONFLICT('An account with that email already exists'); if (await getUserByEmail(email)) throw errors.CONFLICT('An account with that email already exists');
@@ -95,16 +109,36 @@ export const createUserHandler: Handler = async function (ctx) {
// at the moment they click rather than discovering it when a terminal opens in the wrong home. // at the moment they click rather than discovering it when a terminal opens in the wrong home.
let osUser: string | null = null; let osUser: string | null = null;
let osUserError: string | null = null; let osUserError: string | null = null;
let sshPublicKey: string | null = null;
if (OS_USERS_ENABLED) { if (OS_USERS_ENABLED) {
const result = await ensureOsUser({ email, username }); const result = await ensureOsUser({ email, username });
if (result.ok) { if (result.ok) {
osUser = result.osUser; osUser = result.osUser;
await updateUser(user.id, { osUser: result.osUser });
// SSH after the account, because everything it writes lives inside a home that does not belong to
// us until `ensureOsUser` has chowned it.
const ssh = await provisionSshAccess({
email,
osUser: result.osUser,
uid: result.uid,
gid: result.gid,
authorizedKey: inboundKey,
});
if (ssh.ok) {
sshPublicKey = ssh.publicKey;
} else {
// The Linux account is real and usable either way — it just has no keys yet. Reported rather than
// thrown for the same reason as the rest of this block.
osUserError = ssh.error;
console.warn(`[users] created the Linux account for ${email} but SSH setup failed: ${ssh.error}`);
}
await updateUser(user.id, { osUser: result.osUser, osSshPublicKey: sshPublicKey });
} else { } else {
osUserError = result.error; osUserError = result.error;
console.warn(`[users] created ${email} but could not create its Linux account: ${result.error}`); console.warn(`[users] created ${email} but could not create its Linux account: ${result.error}`);
} }
} }
return ctx.json({ user: { ...toPublicUser(user), osUser }, osUserError }, 201); return ctx.json({ user: { ...toPublicUser(user), osUser, osSshPublicKey: sshPublicKey }, osUserError }, 201);
}; };
+10
View File
@@ -19,6 +19,14 @@ type PublicUser = {
createdAt: Date; createdAt: Date;
/** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */ /** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */
isOwner: boolean; isOwner: boolean;
/** The Linux account this runs as, or null where per-user OS accounts are off. */
osUser: string | null;
/**
* The public half of their generated SSH key. Listed because it has an errand attached — it must be
* added to their Gitea account — and the create form promises it is retrievable here afterwards. Public
* by definition, so no reason to withhold it from the owner-only endpoint that already returns emails.
*/
osSshPublicKey: string | null;
}; };
/** Shared with create-user.ts, so a created account and a listed one are described the same way. */ /** Shared with create-user.ts, so a created account and a listed one are described the same way. */
@@ -32,6 +40,8 @@ export const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): P
role: u.role, role: u.role,
createdAt: u.createdAt, createdAt: u.createdAt,
isOwner: u.id === OWNER_USER_ID, isOwner: u.id === OWNER_USER_ID,
osUser: u.osUser,
osSshPublicKey: u.osSshPublicKey,
}); });
export const listUsersHandler: Handler = async function (ctx) { export const listUsersHandler: Handler = async function (ctx) {
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, test } from 'bun:test';
import { validatePublicKey } from './os-user-ssh';
// `authorized_keys` is a file where every LINE is a credential, so the validation that matters is not
// "does this look like a key" — it is "is this exactly one".
describe('validatePublicKey', () => {
const ed25519 = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHhTb2Rhb25lc3RyaW5nb2ZiYXNlNjRoZXJlMTIz ana@laptop';
test('accepts an ed25519 key with a comment', () => {
expect(validatePublicKey(ed25519)).toEqual({ ok: true, key: ed25519 });
});
test('accepts one with no comment, and trims surrounding whitespace', () => {
const bare = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHhTb2Rhb25lc3RyaW5nb2ZiYXNlNjRoZXJlMTIz';
expect(validatePublicKey(` ${bare}\n`)).toEqual({ ok: true, key: bare });
});
test('accepts rsa and ecdsa', () => {
expect(validatePublicKey('ssh-rsa AAAAB3NzaC1yc2EAAAAsomethinglongenough== a@b').ok).toBe(true);
expect(validatePublicKey('ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY= a@b').ok).toBe(true);
});
// THE case this function exists for. A second line is a second authorized key, and it would be granted
// silently — the caller only ever looks at whether the write succeeded.
test('refuses a second line, which would inject a second credential', () => {
const injected = `${ed25519}\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEludHJ1ZGVyc0tleUhlcmVQYWRkaW5nMTIz attacker@elsewhere`;
const result = validatePublicKey(injected);
expect(result.ok).toBe(false);
expect(result).toMatchObject({ error: expect.stringContaining('single line') });
});
test('refuses a carriage return too', () => {
expect(validatePublicKey(`${ed25519}\r\nssh-rsa AAAAB3Nz== x@y`).ok).toBe(false);
});
// Pasting the private half instead of the .pub is a genuine mistake, and it deserves a message that
// says so rather than "not a public key".
test('names the mistake when handed a private key', () => {
const priv = '-----BEGIN OPENSSH PRIVATE KEY-----';
expect(validatePublicKey(priv)).toMatchObject({ ok: false, error: expect.stringContaining('PRIVATE') });
});
test('refuses junk, an empty string and a bare algorithm name', () => {
expect(validatePublicKey('hello').ok).toBe(false);
expect(validatePublicKey('').ok).toBe(false);
expect(validatePublicKey('ssh-ed25519').ok).toBe(false);
expect(validatePublicKey('ssh-ed25519 not!valid!base64!').ok).toBe(false);
});
// An `authorized_keys` options prefix (`command="…" ssh-ed25519 …`) is a legitimate OpenSSH line but not
// something this form should accept: it can force a command or disable a restriction, and it is not what
// anyone pastes by accident.
test('refuses an options prefix', () => {
expect(validatePublicKey(`command="/bin/sh" ${ed25519}`).ok).toBe(false);
});
});
+171
View File
@@ -0,0 +1,171 @@
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 };
}