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 && (
+
+
+
+
+
+
+ {/* 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. */}
+
+ Generated on the machine; the private half never leaves it.{' '}
+ Add this to their Gitea account so they can push. Retrievable later from their row.
+
+
+ )}
+
+ {created.osUserError && (
+
+
The account works, but its Linux side did not finish
+
{created.osUserError}
+
+ )}
+
+
+
+ );
+ }
+
if (!open) {
return (
+
+ {/* 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. */}
+
{user.email}
{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}`}
@@ -134,6 +139,24 @@ export const UsersSection = () => {
+ {/* 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 && (
+
+ )}
+