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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user