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 && ( +
+ +
+