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
+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);
});
});