From 0fb9a29e642e002d113e347219c2e1801cb343f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 11 Aug 2026 17:02:33 +0000 Subject: [PATCH] ssh for a member's linux account, both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/per-user-linux-accounts.md | 51 ++++++ .../UserManagement/CreateUserForm.tsx | 136 +++++++++++++- .../Settings/UserManagement/UsersSection.tsx | 25 ++- src/databases/officer_db/src/schema/auth.ts | 8 + src/servers/api/users/create-user.ts | 38 +++- src/servers/api/users/manage-users.ts | 10 + src/servers/os-user-ssh.test.ts | 57 ++++++ src/servers/os-user-ssh.ts | 171 ++++++++++++++++++ 8 files changed, 484 insertions(+), 12 deletions(-) create mode 100644 src/servers/os-user-ssh.test.ts create mode 100644 src/servers/os-user-ssh.ts diff --git a/docs/per-user-linux-accounts.md b/docs/per-user-linux-accounts.md index 1e386a2c..7e92c825 100644 --- a/docs/per-user-linux-accounts.md +++ b/docs/per-user-linux-accounts.md @@ -235,6 +235,57 @@ shell is. So: 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 - **Deleting a member no longer removes their home.** It belongs to their uid, so the platform cannot diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx index e0e1b79c..46c887cc 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx @@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; 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. @@ -45,7 +46,22 @@ function generatePassword(): string { 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) => { const client = useClient(); @@ -53,28 +69,32 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => { const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [form, setForm] = useState(EMPTY); + const [created, setCreated] = useState(null); const set = (key: keyof typeof EMPTY) => (value: string) => setForm((prev) => ({ ...prev, [key]: value })); const close = () => { setOpen(false); setForm(EMPTY); + setCreated(null); }; const submit = async (ev: React.FormEvent) => { ev.preventDefault(); setSaving(true); 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 }); - // The password is named in the toast on purpose. It is the only moment it is recoverable — the - // server stores an argon2 hash and there is no reset flow yet, so an owner who closed the form - // without noting it would have to delete the account and make it again. - toast.success(`${form.email} created`, { - description: `Password: ${form.password}`, - duration: 30_000, + setCreated({ + email: form.email, + password: form.password, + osUser: res.user.osUser, + osSshPublicKey: res.user.osSshPublicKey, + osUserError: res.osUserError, }); - close(); } catch (ex) { // 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'); @@ -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 ( +
+
+

{created.email} created

+

+ Copy what you need before closing — none of it can be shown again. +

+
+ +
+ +
+ + +
+

+ 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. +

+
+ + {created.osUser && ( +
+ + +
+ )} + + {created.osSshPublicKey && ( +
+ +
+