Passkeys, their sessions, tokens and one-time enrollment links live in one SQLite file, .forge/identity.db, opened with node:sqlite by whatever needs it, in WAL mode with a busy timeout. Secrets are kept as SHA-256 hashes only, every row has a short id to list and remove it by, and the SQL is the plain subset Turso runs too. SSH keys stay the root of identity: credentials are made over ssh (passkey prints a link that works once for ten minutes; token prints a token once, for one repo or everything you can read, 30 days unless --ttl), listed and removed with passkeys, sessions and tokens, and forge user rm removes them with the keys. forge server init creates the file. lib/access.ts has can_read and the users list for the servers to come, and identity-can-read runs both rules over one table of cases, so the sh and TypeScript sides can't drift apart.
- commit
- a7d5191463dc80594059f93472781cd544919f55
- parent
- d85609a831f1c1778750c398d2ccac2e76a3f2f7
- author
- cat <admin@dev.site>
- kind
- feat risk score 2.0 of 3, Jev's estimate
| M | bin/ | 26 | +- |
| A | lib/ | 105 | ++++++ |
| A | lib/ | 533 | ++++++++++++++++++++++++++++++ |
| A | test/ | 63 | ++++ |
| A | test/ | 45 | +++ |
| A | test/ | 64 | ++++ |
@@ -979,7 +979,7 @@ cmd_server_init() {
979979 # FORGE_SITES_URL=/sites/{owner}/{repo}/{site}/980980 # FORGE_PREVIEWS_URL=/previews/{owner}/{repo}/{site}/{branch}/981981 EOF982- for c in new ls whoami signers site-url reviews comment approve approvals land private public readers release help; do982+ for c in new ls whoami signers site-url reviews comment approve approvals land private public readers release passkey passkeys sessions token tokens help; do983983 printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"984984 chmod +x "$FORGE_ROOT/git-shell-commands/$c"985985 done@@ -988,6 +988,8 @@ cmd_server_init() {
988988 for d in "$FORGE_ROOT"/*/*.git; do989989 if [ -f "$d/HEAD" ]; then repo_setup "$d"; fi990990 done991+ # Web credentials (lib/identity.ts), there for the servers and litestream.992+ node "$FORGE_HOME/lib/identity.ts" init991993 cmd_keys_sync992994 say "initialized $FORGE_ROOT (state in $FORGE_STATE, pages in $FORGE_WWW)"993995 }@@ -1132,7 +1134,9 @@ cmd_user() {
11321134 grep -v "^$2 " "$FORGE_STATE/keys" >"$FORGE_STATE/keys.tmp" || true11331135 mv "$FORGE_STATE/keys.tmp" "$FORGE_STATE/keys"11341136 cmd_keys_sync1135- say "removed $2's keys (repos under $FORGE_ROOT/$2 are kept)"1137+ # Their passkeys, sessions and tokens go with the keys.1138+ node "$FORGE_HOME/lib/identity.ts" purge "$2"1139+ say "removed $2's keys and web sign-ins (repos under $FORGE_ROOT/$2 are kept)"11361140 return11371141 fi11381142 [ "${1:-}" = add ] && [ $# -eq 3 ] || die "usage: forge user add <name> <pubkey-file|'ssh-ed25519 AAAA...'|-> | user rm <name>"@@ -1730,6 +1734,10 @@ sc_release() {
17301734 esac17311735 }17321736 1737+# identity <command> [args]: the caller's web credentials, which live in1738+# .forge/identity.db (lib/identity.ts): passkeys and their sessions, tokens.1739+identity() { FORGE_USER=$user node "$FORGE_HOME/lib/identity.ts" "$@"; }1740+17331741 # Commands reachable over ssh through git-shell-commands/.17341742 cmd_shell_cmd() {17351743 load_config@@ -1791,7 +1799,19 @@ cmd_shell_cmd() {
17911799 public) sc_public "$@" ;;17921800 readers) sc_readers "$@" ;;17931801 release) sc_release "$@" ;;1794- *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;1802+ passkey | passkeys | sessions | tokens) identity "$c" "$@" ;;1803+ token)1804+ # A token for one repo needs a repo you can read, or it isn't there.1805+ r= next=01806+ for a in "$@"; do1807+ [ "$next" = 0 ] || r=$a1808+ next=01809+ [ "$a" != --repo ] || next=11810+ done1811+ [ -z "$r" ] || open_repo "$r"1812+ identity token "$@"1813+ ;;1814+ *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;17951815 esac17961816 }17971817 @@ -0,0 +1,105 @@
1+// Who may read what, for the servers: the rules bin/forge applies over ssh,2+// in TypeScript. A repo is public while its bare repo has3+// git-daemon-export-ok; a private one can be read by its owner and the names4+// in its forge/collaborators and forge/readers (can_read in bin/forge).5+// Users are the names in .forge/keys, the forge's only source of identity,6+// so someone whose keys are removed can't read anything here either.7+// test/scenarios/identity-can-read.sh checks that both sides agree.8+//9+// node lib/access.ts can-read <git-dir> <user> exits 0 if they may10+import fs from 'node:fs';11+import path from 'node:path';12+import { fileURLToPath } from 'node:url';13+14+// valid_name in bin/forge: owner and repo names, and user names.15+export function validName(n: string): boolean {16+ if (!n || n.startsWith('.') || !/^[A-Za-z0-9._-]+$/.test(n) || n.endsWith('.git')) return false;17+ return !['assets', 'sites', 'previews', 'hosts', 'git-shell-commands'].includes(n);18+}19+20+const isFile = (p: string): boolean => {21+ try {22+ return fs.statSync(p).isFile();23+ } catch {24+ return false;25+ }26+};27+28+// The bare repo of owner/name, if it is one: valid names, and a HEAD.29+export function repoDir(root: string, slug: string): string | null {30+ const [owner, name, ...rest] = slug.split('/');31+ if (rest.length || !validName(owner ?? '') || !validName(name ?? '')) return null;32+ const dir = path.join(root, owner, `${name}.git`);33+ return isFile(path.join(dir, 'HEAD')) ? dir : null;34+}35+36+export const isPublic = (dir: string): boolean => isFile(path.join(dir, 'git-daemon-export-ok'));37+38+// Whole lines, as grep -qxF matches them.39+function listed(file: string, user: string): boolean {40+ try {41+ return fs.readFileSync(file, 'utf8').split('\n').includes(user);42+ } catch {43+ return false;44+ }45+}46+47+// can_read <git-dir> <user>.48+export function canRead(dir: string, user: string | null): boolean {49+ if (isPublic(dir)) return true;50+ if (!user) return false;51+ if (path.basename(path.dirname(dir)) === user) return true;52+ return listed(path.join(dir, 'forge', 'collaborators'), user) || listed(path.join(dir, 'forge', 'readers'), user);53+}54+55+// The users of the forge: the first word of each line of .forge/keys.56+// Read again only when the file changes.57+let seen: { file: string; mtime: number; names: Set<string> } | null = null;58+export function users(state: string): Set<string> {59+ const file = path.join(state, 'keys');60+ let mtime = -1;61+ try {62+ mtime = fs.statSync(file).mtimeMs;63+ } catch {}64+ if (seen?.file === file && seen.mtime === mtime) return seen.names;65+ const names = new Set<string>();66+ if (mtime >= 0) {67+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {68+ const name = line.trim().split(/\s+/)[0];69+ if (name && !name.startsWith('#')) names.add(name);70+ }71+ }72+ seen = { file, mtime, names };73+ return names;74+}75+76+// Every private repo user may read, as owner/name, in the order ls lists them.77+export function readable(root: string, user: string): string[] {78+ const out: string[] = [];79+ let owners: string[] = [];80+ try {81+ owners = fs.readdirSync(root);82+ } catch {}83+ for (const owner of owners) {84+ if (!validName(owner)) continue;85+ let names: string[] = [];86+ try {87+ names = fs.readdirSync(path.join(root, owner));88+ } catch {}89+ for (const d of names) {90+ if (!d.endsWith('.git')) continue;91+ const dir = repoDir(root, `${owner}/${d.slice(0, -4)}`);92+ if (dir && !isPublic(dir) && canRead(dir, user)) out.push(`${owner}/${d.slice(0, -4)}`);93+ }94+ }95+ return out.sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }) || (a < b ? -1 : a > b ? 1 : 0));96+}97+98+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {99+ const [cmd, dir, user] = process.argv.slice(2);100+ if (cmd !== 'can-read' || !dir) {101+ console.error('usage: node lib/access.ts can-read <git-dir> <user>');102+ process.exit(2);103+ }104+ process.exit(canRead(dir, user || null) ? 0 : 1);105+}@@ -0,0 +1,533 @@
1+// Web credentials, in one SQLite file, $FORGE_STATE/identity.db: passkeys,2+// the sessions they sign in, tokens for curl and HTTPS clones, and one-time3+// enrollment links. SSH keys stay the root of identity: a user is a name in4+// .forge/keys, and every credential here belongs to one and stops working5+// when their keys go (lib/access.ts users). New credentials come from ssh6+// commands only (passkey, token), so losing every passkey is fixed by7+// running ssh again; there are no passwords and no email.8+//9+// Secrets (session cookies, tokens, enrollment codes) are stored as SHA-25610+// hashes only, so the file and its backups can't sign anyone in. Every row11+// has a short random id to list it by and remove it with.12+//13+// Any process that needs the file opens it: the private and events servers14+// and the ssh commands. It is in WAL mode with a busy timeout, so they don't15+// get in each other's way. The SQL is the plain subset Turso runs too (plain16+// tables and indexes, no triggers or exotic pragmas), so moving to it would17+// be a change of library. meta.changed is bumped by every write, so a18+// restore can tell which of two copies is newer (bin/forge restore).19+//20+// node lib/identity.ts passkey [name] (as FORGE_USER) a one-time enrollment link21+// node lib/identity.ts passkeys [rm <id>]22+// node lib/identity.ts sessions [rm <id>|--all]23+// node lib/identity.ts token [--repo <owner>/<repo>] [--ttl 30d] [name]24+// node lib/identity.ts tokens [rm <id>]25+// node lib/identity.ts init | purge <user> | snapshot <file> | changed <file>26+import fs from 'node:fs';27+import path from 'node:path';28+import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';29+import { fileURLToPath } from 'node:url';30+import type { DatabaseSync as Database, StatementSync } from 'node:sqlite';31+32+// node:sqlite warns that it is experimental when it loads, on Node 22. The33+// warning would reach ssh users, so it is dropped (and only it).34+const printWarning = process.listeners('warning');35+process.removeAllListeners('warning');36+process.on('warning', (w) => {37+ if (w.name === 'ExperimentalWarning' && /SQLite/.test(w.message)) return;38+ for (const f of printWarning) f.call(process, w);39+});40+const { DatabaseSync } = process.getBuiltinModule('node:sqlite') as typeof import('node:sqlite');41+42+export const HOUR = 3600;43+export const DAY = 24 * HOUR;44+// An enrollment link works once, within this long.45+export const ENROLL_TTL = 10 * 60;46+// Sessions slide: each use (at most hourly) keeps one alive this long.47+export const SESSION_TTL = 30 * DAY;48+export const TOKEN_TTL = 30 * DAY;49+export const TOKEN_MAX = 365 * DAY;50+51+// The clock: FORGE_NOW pins it, for the demo and tests (as for review logs).52+export const now = (): number => Number(process.env.FORGE_NOW) || Math.floor(Date.now() / 1000);53+54+export const hash = (secret: string): string => createHash('sha256').update(secret).digest('hex');55+const secret = (n: number): string => randomBytes(n).toString('base64url');56+const newId = (): string => randomBytes(4).toString('hex');57+export const TOKEN_PREFIX = 'forge_';58+59+export function sameSecret(a: string, b: string): boolean {60+ const x = Buffer.from(a);61+ const y = Buffer.from(b);62+ return x.length === y.length && timingSafeEqual(x, y);63+}64+65+const SCHEMA = `66+CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);67+CREATE TABLE IF NOT EXISTS users (68+ name TEXT PRIMARY KEY,69+ handle TEXT NOT NULL UNIQUE,70+ created INTEGER NOT NULL71+);72+CREATE TABLE IF NOT EXISTS passkeys (73+ id TEXT PRIMARY KEY,74+ credential TEXT NOT NULL UNIQUE,75+ user TEXT NOT NULL,76+ public_key BLOB NOT NULL,77+ counter INTEGER NOT NULL,78+ transports TEXT NOT NULL,79+ name TEXT NOT NULL,80+ created INTEGER NOT NULL,81+ last_used INTEGER82+);83+CREATE INDEX IF NOT EXISTS passkeys_user ON passkeys (user);84+CREATE TABLE IF NOT EXISTS sessions (85+ id TEXT PRIMARY KEY,86+ hash TEXT NOT NULL UNIQUE,87+ user TEXT NOT NULL,88+ passkey TEXT,89+ agent TEXT NOT NULL,90+ created INTEGER NOT NULL,91+ last_used INTEGER NOT NULL,92+ expires INTEGER NOT NULL93+);94+CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user);95+CREATE TABLE IF NOT EXISTS tokens (96+ id TEXT PRIMARY KEY,97+ hash TEXT NOT NULL UNIQUE,98+ user TEXT NOT NULL,99+ name TEXT NOT NULL,100+ repo TEXT,101+ created INTEGER NOT NULL,102+ expires INTEGER NOT NULL,103+ last_used INTEGER104+);105+CREATE INDEX IF NOT EXISTS tokens_user ON tokens (user);106+CREATE TABLE IF NOT EXISTS enrollments (107+ id TEXT PRIMARY KEY,108+ hash TEXT NOT NULL UNIQUE,109+ user TEXT NOT NULL,110+ name TEXT NOT NULL,111+ created INTEGER NOT NULL,112+ expires INTEGER NOT NULL113+);114+`;115+116+// A passkey's public key is a COSE key: 77 bytes for ES256, about 1.3K for117+// ML-DSA-44 (1312 bytes of key), which a BLOB holds either way.118+export interface Passkey {119+ id: string;120+ credential: string;121+ user: string;122+ public_key: Uint8Array;123+ counter: number;124+ transports: string;125+ name: string;126+ created: number;127+ last_used: number | null;128+}129+130+export interface Session {131+ id: string;132+ user: string;133+ passkey: string | null;134+ agent: string;135+ created: number;136+ last_used: number;137+ expires: number;138+}139+140+export interface Token {141+ id: string;142+ user: string;143+ name: string;144+ // owner/repo, or null: every repo the user can read when it is used.145+ repo: string | null;146+ created: number;147+ expires: number;148+ last_used: number | null;149+}150+151+export interface Enrollment {152+ id: string;153+ user: string;154+ name: string;155+ created: number;156+ expires: number;157+}158+159+export const dbPath = (state: string): string => path.join(state, 'identity.db');160+161+export class Identity {162+ db: Database;163+ readonly: boolean;164+ private stmts = new Map<string, StatementSync>();165+166+ private constructor(file: string, readOnly: boolean) {167+ this.readonly = readOnly;168+ const fresh = !readOnly && !fs.existsSync(file);169+ this.db = new DatabaseSync(file, { readOnly });170+ this.db.exec('PRAGMA busy_timeout = 5000');171+ if (readOnly) return;172+ // Only git (the forge's user) reads it; SQLite gives -wal and -shm the same mode.173+ if (fresh) fs.chmodSync(file, 0o600);174+ this.db.exec('PRAGMA journal_mode = WAL');175+ this.db.exec(SCHEMA);176+ this.q("INSERT OR IGNORE INTO meta (k, v) VALUES ('schema', '1')").run();177+ }178+179+ // The forge's identity.db, created if missing; with readOnly, null if missing.180+ static open(state: string, opts: { readOnly?: boolean } = {}): Identity | null {181+ const file = dbPath(state);182+ if (opts.readOnly && !fs.existsSync(file)) return null;183+ return new Identity(file, !!opts.readOnly);184+ }185+186+ // When the copy in file last changed, without writing to it.187+ static changedAt(file: string): number {188+ const db = new DatabaseSync(file, { readOnly: true });189+ try {190+ const r = db.prepare("SELECT v FROM meta WHERE k = 'changed'").get() as { v: string } | undefined;191+ return Number(r?.v ?? 0);192+ } finally {193+ db.close();194+ }195+ }196+197+ close(): void {198+ this.db.close();199+ }200+201+ private q(sql: string): StatementSync {202+ let s = this.stmts.get(sql);203+ if (!s) this.stmts.set(sql, (s = this.db.prepare(sql)));204+ return s;205+ }206+207+ // A write: in one transaction, with meta.changed bumped.208+ private write<T>(fn: () => T): T {209+ this.db.exec('BEGIN IMMEDIATE');210+ try {211+ const out = fn();212+ this.q("INSERT OR REPLACE INTO meta (k, v) VALUES ('changed', ?)").run(String(now()));213+ this.db.exec('COMMIT');214+ return out;215+ } catch (e) {216+ this.db.exec('ROLLBACK');217+ throw e;218+ }219+ }220+221+ private prune(t: number): void {222+ for (const table of ['enrollments', 'sessions', 'tokens']) this.q(`DELETE FROM ${table} WHERE expires <= ?`).run(t);223+ }224+225+ // ---------------------------------------------------------- enrollment226+227+ // A one-time code that lets whoever opens the link add a passkey for user.228+ enroll(user: string, name: string): { code: string; expires: number } {229+ const t = now();230+ const code = secret(18);231+ this.write(() => {232+ this.prune(t);233+ this.q('INSERT INTO enrollments (id, hash, user, name, created, expires) VALUES (?, ?, ?, ?, ?, ?)').run(newId(), hash(code), user, name, t, t + ENROLL_TTL);234+ });235+ return { code, expires: t + ENROLL_TTL };236+ }237+238+ enrollment(code: string): Enrollment | null {239+ const r = this.q('SELECT id, user, name, created, expires FROM enrollments WHERE hash = ? AND expires > ?').get(hash(code), now());240+ return (r as unknown as Enrollment) ?? null;241+ }242+243+ // Use the code up and add the passkey, or nothing if it was used meanwhile.244+ enrolled(code: string, key: Omit<Passkey, 'id' | 'user' | 'created' | 'last_used' | 'counter'> & { counter: number }): Passkey | null {245+ const t = now();246+ return this.write(() => {247+ const e = this.q('SELECT user, name FROM enrollments WHERE hash = ? AND expires > ?').get(hash(code), t) as { user: string; name: string } | undefined;248+ if (!e) return null;249+ this.q('DELETE FROM enrollments WHERE hash = ?').run(hash(code));250+ const p: Passkey = { ...key, id: newId(), user: e.user, name: e.name || key.name, created: t, last_used: t };251+ this.q('INSERT INTO passkeys (id, credential, user, public_key, counter, transports, name, created, last_used) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')252+ .run(p.id, p.credential, p.user, p.public_key, p.counter, p.transports, p.name, p.created, p.last_used);253+ return p;254+ });255+ }256+257+ // ---------------------------------------------------------- passkeys258+259+ // The user's WebAuthn user handle: random, made the first time it is needed.260+ handle(user: string): string {261+ const r = this.q('SELECT handle FROM users WHERE name = ?').get(user) as { handle: string } | undefined;262+ if (r) return r.handle;263+ const h = secret(16);264+ this.write(() => this.q('INSERT OR IGNORE INTO users (name, handle, created) VALUES (?, ?, ?)').run(user, h, now()));265+ return (this.q('SELECT handle FROM users WHERE name = ?').get(user) as { handle: string }).handle;266+ }267+268+ userOfHandle(handle: string): string | null {269+ const r = this.q('SELECT name FROM users WHERE handle = ?').get(handle) as { name: string } | undefined;270+ return r?.name ?? null;271+ }272+273+ passkey(credential: string): Passkey | null {274+ return (this.q('SELECT * FROM passkeys WHERE credential = ?').get(credential) as unknown as Passkey) ?? null;275+ }276+277+ passkeys(user: string): Passkey[] {278+ return this.q('SELECT * FROM passkeys WHERE user = ? ORDER BY created, id').all(user) as unknown as Passkey[];279+ }280+281+ usedPasskey(credential: string, counter: number): void {282+ this.write(() => this.q('UPDATE passkeys SET counter = ?, last_used = ? WHERE credential = ?').run(counter, now(), credential));283+ }284+285+ // Remove a passkey, and sign out the sessions it signed in.286+ removePasskey(user: string, id: string): { passkey: Passkey; sessions: number } | null {287+ return this.write(() => {288+ const p = this.q('SELECT * FROM passkeys WHERE user = ? AND id = ?').get(user, id) as unknown as Passkey | undefined;289+ if (!p) return null;290+ this.q('DELETE FROM passkeys WHERE id = ?').run(id);291+ const n = Number(this.q('DELETE FROM sessions WHERE passkey = ?').run(id).changes);292+ return { passkey: p, sessions: n };293+ });294+ }295+296+ // ---------------------------------------------------------- sessions297+298+ createSession(user: string, passkey: string | null, agent: string): { secret: string; session: Session } {299+ const t = now();300+ const s = secret(32);301+ const session: Session = { id: newId(), user, passkey, agent: agent.slice(0, 100), created: t, last_used: t, expires: t + SESSION_TTL };302+ this.write(() => {303+ this.prune(t);304+ this.q('INSERT INTO sessions (id, hash, user, passkey, agent, created, last_used, expires) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')305+ .run(session.id, hash(s), user, passkey, session.agent, t, t, session.expires);306+ });307+ return { secret: s, session };308+ }309+310+ session(secretValue: string): Session | null {311+ if (!secretValue) return null;312+ const r = this.q('SELECT id, user, passkey, agent, created, last_used, expires FROM sessions WHERE hash = ? AND expires > ?').get(hash(secretValue), now());313+ return (r as unknown as Session) ?? null;314+ }315+316+ // Keep a session alive: at most once an hour, so reads stay reads. Returns317+ // whether it did (then the cookie is sent again with a fresh lifetime).318+ touchSession(s: Session): boolean {319+ const t = now();320+ if (this.readonly || t - s.last_used < HOUR) return false;321+ this.write(() => this.q('UPDATE sessions SET last_used = ?, expires = ? WHERE id = ?').run(t, t + SESSION_TTL, s.id));322+ s.last_used = t;323+ s.expires = t + SESSION_TTL;324+ return true;325+ }326+327+ sessions(user: string): Session[] {328+ return this.q('SELECT id, user, passkey, agent, created, last_used, expires FROM sessions WHERE user = ? AND expires > ? ORDER BY created, id').all(user, now()) as unknown as Session[];329+ }330+331+ // One session of user's (by id), or all of them (id null). How many went.332+ removeSessions(user: string, id: string | null): number {333+ return this.write(() => Number((id ? this.q('DELETE FROM sessions WHERE user = ? AND id = ?').run(user, id) : this.q('DELETE FROM sessions WHERE user = ?').run(user)).changes));334+ }335+336+ endSession(secretValue: string): void {337+ this.write(() => this.q('DELETE FROM sessions WHERE hash = ?').run(hash(secretValue)));338+ }339+340+ // ---------------------------------------------------------- tokens341+342+ createToken(user: string, name: string, repo: string | null, ttl: number): { token: string; row: Token } {343+ const t = now();344+ const token = TOKEN_PREFIX + secret(24);345+ const row: Token = { id: newId(), user, name, repo, created: t, expires: t + ttl, last_used: null };346+ this.write(() => {347+ this.prune(t);348+ this.q('INSERT INTO tokens (id, hash, user, name, repo, created, expires, last_used) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)').run(row.id, hash(token), user, name, repo, t, row.expires);349+ });350+ return { token, row };351+ }352+353+ token(value: string): Token | null {354+ if (!value.startsWith(TOKEN_PREFIX)) return null;355+ const r = this.q('SELECT id, user, name, repo, created, expires, last_used FROM tokens WHERE hash = ? AND expires > ?').get(hash(value), now());356+ return (r as unknown as Token) ?? null;357+ }358+359+ // Record a use, at most once a minute.360+ usedToken(tok: Token): void {361+ const t = now();362+ if (this.readonly || (tok.last_used != null && t - tok.last_used < 60)) return;363+ this.write(() => this.q('UPDATE tokens SET last_used = ? WHERE id = ?').run(t, tok.id));364+ tok.last_used = t;365+ }366+367+ tokens(user: string): Token[] {368+ return this.q('SELECT id, user, name, repo, created, expires, last_used FROM tokens WHERE user = ? AND expires > ? ORDER BY created, id').all(user, now()) as unknown as Token[];369+ }370+371+ removeToken(user: string, id: string): Token | null {372+ return this.write(() => {373+ const r = this.q('SELECT id, user, name, repo, created, expires, last_used FROM tokens WHERE user = ? AND id = ?').get(user, id) as unknown as Token | undefined;374+ if (r) this.q('DELETE FROM tokens WHERE id = ?').run(id);375+ return r ?? null;376+ });377+ }378+379+ // ---------------------------------------------------------- the forge's side380+381+ // Everything of a user's, when their keys are removed (forge user rm).382+ purge(user: string): void {383+ this.write(() => {384+ for (const table of ['passkeys', 'sessions', 'tokens', 'enrollments']) this.q(`DELETE FROM ${table} WHERE user = ?`).run(user);385+ this.q('DELETE FROM users WHERE name = ?').run(user);386+ });387+ }388+389+ // A consistent copy in one file, for backups: never the live file.390+ snapshot(file: string): void {391+ fs.rmSync(file, { force: true });392+ fs.writeFileSync(file, '', { mode: 0o600 });393+ this.db.prepare('VACUUM INTO ?').run(file);394+ }395+}396+397+// ------------------------------------------------------------- ssh commands398+399+const when = (t: number | null): string => (t ? new Date(t * 1000).toISOString().slice(0, 16).replace('T', ' ') : 'never');400+401+function columns(head: string[], rows: string[][]): string {402+ const all = [head, ...rows];403+ const width = head.map((_, i) => Math.max(...all.map((r) => r[i].length)));404+ return all.map((r) => r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(width[i]))).join(' ')).join('\n') + '\n';405+}406+407+class Usage extends Error {}408+const fail = (msg: string): never => {409+ throw new Usage(msg);410+};411+412+// 30d, 12h: seconds. At most TOKEN_MAX.413+export function ttl(s: string): number {414+ const m = /^([1-9][0-9]{0,4})([hd])$/.exec(s);415+ if (!m) fail(`token: --ttl wants a number of days or hours, like 30d or 12h, not ${s}`);416+ const n = Number(m![1]) * (m![2] === 'd' ? DAY : HOUR);417+ if (n > TOKEN_MAX) fail('token: --ttl is at most 365d');418+ return n;419+}420+421+// A name for a passkey or token, as given: one line of plain text.422+function label(words: string[], what: string): string {423+ const s = words.join(' ').trim();424+ if (s.length > 64 || /[\u0000-\u001f\u007f]/.test(s)) fail(`${what}: the name is one line of at most 64 characters`);425+ return s;426+}427+428+function sshCommand(state: string, user: string, cmd: string, args: string[], out: (s: string) => void, err: (s: string) => void): void {429+ const base = (process.env.FORGE_BASE_URL || '').replace(/\/$/, '');430+ const ssh = process.env.FORGE_SSH || 'git@host';431+ const id = Identity.open(state)!;432+ switch (cmd) {433+ case 'passkey': {434+ const name = label(args, 'passkey');435+ const { code } = id.enroll(user, name);436+ out(`Open this link within ${ENROLL_TTL / 60} minutes, in the browser or on the device that should keep the passkey; it works once:\n\n ${base}/login/#enroll=${code}\n\n`);437+ if (!base) out('(FORGE_BASE_URL is not set, so that is only the path.)\n');438+ out(`It adds a passkey for ${user}, which signs you in to see private repos' pages. Lost them all? Run ssh ${ssh} passkey again.\n`);439+ return;440+ }441+ case 'passkeys': {442+ if (args[0] === 'rm') {443+ if (args.length !== 2) fail('usage: passkeys [rm <id>]');444+ const r = id.removePasskey(user, args[1]);445+ if (!r) fail(`no passkey ${args[1]} (passkeys lists yours)`);446+ out(`removed passkey ${r!.passkey.id}${r!.passkey.name ? ` (${r!.passkey.name})` : ''}${r!.sessions ? `, and signed out the ${r!.sessions === 1 ? 'session' : `${r!.sessions} sessions`} it signed in` : ''}\n`);447+ out("Your browser or password manager may still offer it; the next time you sign in there, the forge tells it which passkeys it still takes.\n");448+ return;449+ }450+ if (args.length) fail('usage: passkeys [rm <id>]');451+ const list = id.passkeys(user);452+ if (!list.length) return out(`no passkeys; ssh ${ssh} passkey adds one\n`);453+ out(columns(['id', 'added', 'last used', 'name'], list.map((p) => [p.id, when(p.created), when(p.last_used), p.name || '-'])));454+ return;455+ }456+ case 'sessions': {457+ // rm <id> signs one out; --all (or rm --all) every one.458+ if (args[0] === 'rm' || (args[0] === '--all' && args.length === 1)) {459+ if (args[0] === 'rm' && args.length !== 2) fail('usage: sessions [rm <id>|--all]');460+ const all = args[args.length - 1] === '--all';461+ const n = id.removeSessions(user, all ? null : args[1]);462+ if (!all && !n) fail(`no session ${args[1]} (sessions lists yours)`);463+ out(all ? `signed out ${n === 1 ? '1 session' : `${n} sessions`}\n` : `signed out session ${args[1]}\n`);464+ return;465+ }466+ if (args.length) fail('usage: sessions [rm <id>|--all]');467+ const list = id.sessions(user);468+ if (!list.length) return out('no sessions\n');469+ out(columns(['id', 'signed in', 'last used', 'expires', 'browser'], list.map((s) => [s.id, when(s.created), when(s.last_used), when(s.expires), `${s.agent || '-'}${s.passkey ? ` (passkey ${s.passkey})` : ''}`])));470+ return;471+ }472+ case 'token': {473+ let repo: string | null = null;474+ let life = TOKEN_TTL;475+ const words: string[] = [];476+ for (let i = 0; i < args.length; i++) {477+ if (args[i] === '--repo' || args[i] === '--ttl') {478+ if (i + 1 >= args.length) fail(`token: ${args[i]} needs a value`);479+ if (args[i] === '--repo') repo = args[++i];480+ else life = ttl(args[++i]);481+ } else if (args[i].startsWith('-')) fail('usage: token [--repo <owner>/<repo>] [--ttl 30d] [name]');482+ else words.push(args[i]);483+ }484+ if (repo != null && !/^[^/]+\/[^/]+$/.test(repo)) fail(`no repo ${repo}`);485+ const name = label(words, 'token') || 'token';486+ const { token, row } = id.createToken(user, name, repo, life);487+ out(`${token}\n`);488+ const host = base.replace(/^https?:\/\//, '') || 'host';489+ const scheme = base.startsWith('http://') ? 'http' : 'https';490+ const slug = repo ?? '<owner>/<repo>';491+ err(`token ${row.id} "${name}" for ${repo ?? `every repo ${user} can read`}, until ${when(row.expires)} UTC. It is shown only this once.\n`);492+ err(` git clone ${scheme}://${user}:${token}@${host}/${slug}.git\n`);493+ err(` curl -H 'Authorization: Bearer ${token}' ${scheme}://${host}/${slug}/\n`);494+ err(`ssh ${ssh} tokens lists your tokens; tokens rm ${row.id} revokes this one.\n`);495+ return;496+ }497+ case 'tokens': {498+ if (args[0] === 'rm') {499+ if (args.length !== 2) fail('usage: tokens [rm <id>]');500+ const r = id.removeToken(user, args[1]);501+ if (!r) fail(`no token ${args[1]} (tokens lists yours)`);502+ out(`revoked token ${r!.id} "${r!.name}"\n`);503+ return;504+ }505+ if (args.length) fail('usage: tokens [rm <id>]');506+ const list = id.tokens(user);507+ if (!list.length) return out(`no tokens; ssh ${ssh} token makes one\n`);508+ out(columns(['id', 'for', 'made', 'expires', 'last used', 'name'], list.map((t) => [t.id, t.repo ?? '(all you read)', when(t.created), when(t.expires), when(t.last_used), t.name])));509+ return;510+ }511+ }512+ fail(`unknown command ${cmd}`);513+}514+515+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {516+ const [cmd = '', ...args] = process.argv.slice(2);517+ const state = process.env.FORGE_STATE || path.resolve('demo/root/.forge');518+ try {519+ if (cmd === 'init') Identity.open(state)!.close();520+ else if (cmd === 'purge' && args.length === 1) Identity.open(state)!.purge(args[0]);521+ else if (cmd === 'snapshot' && args.length === 1) Identity.open(state)!.snapshot(path.resolve(args[0]));522+ else if (cmd === 'changed' && args.length === 1) console.log(Identity.changedAt(args[0]));523+ else if (['passkey', 'passkeys', 'sessions', 'token', 'tokens'].includes(cmd)) {524+ const user = process.env.FORGE_USER;525+ if (!user) fail('no FORGE_USER');526+ sshCommand(state, user!, cmd, args, (s) => process.stdout.write(s), (s) => process.stderr.write(s));527+ } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | init | purge <user> | snapshot <file> | changed <file>');528+ } catch (e) {529+ if (!(e instanceof Usage)) throw e;530+ process.stderr.write(`forge: ${e.message}\n`);531+ process.exit(1);532+ }533+}@@ -0,0 +1,63 @@
1+# Who may read a repo is decided in two places: can_read in bin/forge (ssh)2+# and canRead in lib/access.ts (the private and events servers). Both get3+# the same table of cases, and must agree with each other and with what it4+# should be: public repos to anyone, private ones to the owner and the5+# exact lines of forge/collaborators and forge/readers.6+. "$(dirname "$0")/../lib.sh"7+for u in dami ana bob cat r.a; do user "$u"; done8+repo dami/open9+repo --private dami/secret10+repo --private dami/odd11+repo --private ana/dir12+s=$FORGE_ROOT/dami/secret.git13+mkdir -p "$s/forge"14+printf 'ana\n' >"$s/forge/collaborators"15+printf 'bob\n\n' >"$s/forge/readers"16+# Near misses: a trailing space, a carriage return, a name inside a longer17+# line, a dot that a regex would let match, no newline at the end.18+o=$FORGE_ROOT/dami/odd.git19+mkdir -p "$o/forge"20+printf 'ana \nbob\r\nxcatx\nr.a' >"$o/forge/readers"21+# A directory is not a flag or a list.22+d=$FORGE_ROOT/ana/dir.git23+mkdir -p "$d/git-daemon-export-ok" "$d/forge/readers"24+25+# The function itself, from bin/forge.26+eval "$(sed -n '/^can_read() {$/,/^}$/p' "$FORGE")"27+type can_read >/dev/null 2>&1 || fail "no can_read in bin/forge"28+29+# repo user want30+while read -r slug who want; do31+ [ "$who" = - ] && who=32+ g=$FORGE_ROOT/$slug.git33+ if can_read "$g" "$who"; then sh_says=yes; else sh_says=no; fi34+ if node "$T_HOME/lib/access.ts" can-read "$g" "$who"; then ts_says=yes; else ts_says=no; fi35+ [ "$sh_says" = "$ts_says" ] || fail "$slug for '$who': can_read says $sh_says, lib/access.ts says $ts_says"36+ [ "$sh_says" = "$want" ] || fail "$slug for '$who': both say $sh_says, want $want"37+done <<'EOF'38+dami/open dami yes39+dami/open ana yes40+dami/open nobody yes41+dami/open - yes42+dami/secret dami yes43+dami/secret ana yes44+dami/secret bob yes45+dami/secret cat no46+dami/secret - no47+dami/secret secret no48+dami/secret da no49+dami/odd dami yes50+dami/odd ana no51+dami/odd bob no52+dami/odd cat no53+dami/odd r.a yes54+dami/odd rxa no55+ana/dir ana yes56+ana/dir dami no57+ana/dir readers no58+EOF59+60+# A flag that is a symlink to a file counts, as test -f has it.61+ln -s ../open.git/git-daemon-export-ok "$o/git-daemon-export-ok"62+if can_read "$o" cat; then :; else fail "can_read: a symlinked flag should make dami/odd public"; fi63+ok node "$T_HOME/lib/access.ts" can-read "$o" cat@@ -0,0 +1,45 @@
1+# The ssh commands for web sign-in: passkey prints a fresh one-time link2+# each time, under FORGE_BASE_URL, whose code identity.db keeps only as a3+# hash; passkeys and sessions list what there is, and say so when nothing4+# matches what you asked to remove. help lists them all.5+. "$(dirname "$0")/../lib.sh"6+user dami7+export FORGE_BASE_URL=https://forge.test8+db=$FORGE_ROOT/.forge/identity.db9+exists "$db"10+11+ok ssh_as dami passkey work laptop12+has "$T_LOG" '^ https://forge\.test/login/#enroll=[A-Za-z0-9_-]{24}$'13+has "$T_LOG" 'within 10 minutes'14+has "$T_LOG" 'it works once'15+one=$(sed -n 's/^ .*#enroll=//p' "$T_LOG")16+ok ssh_as dami passkey17+two=$(sed -n 's/^ .*#enroll=//p' "$T_LOG")18+[ "$one" != "$two" ] || fail "two links with the same code"19+for c in "$one" "$two"; do20+ if grep -aqF "$c" "$db" "$db-wal" 2>/dev/null; then fail "an enrollment code is in identity.db as it is"; fi21+done22+# The name given goes with the link.23+eq "$(node -e '24+const { DatabaseSync } = require("node:sqlite");25+console.log(new DatabaseSync(process.argv[1]).prepare("SELECT name FROM enrollments ORDER BY created, name DESC").all().map((r) => r.name).join(","))' "$db" 2>/dev/null)" "work laptop,"26+27+ok ssh_as dami passkeys28+has "$T_LOG" '^no passkeys; ssh git@forge.test passkey adds one$'29+fails_with 'no passkey 1234abcd' ssh_as dami passkeys rm 1234abcd30+fails_with 'usage: passkeys' ssh_as dami passkeys rm31+ok ssh_as dami sessions32+eq "$(output)" "no sessions"33+fails_with 'no session 1234abcd' ssh_as dami sessions rm 1234abcd34+ok ssh_as dami sessions --all35+eq "$(output)" "signed out 0 sessions"36+ok ssh_as dami sessions rm --all37+fails_with 'usage: sessions' ssh_as dami sessions --all now38+fails_with 'usage: sessions' ssh_as dami sessions rm39+fails_with 'one line of at most 64' ssh_as dami passkey "$(printf '%070d' 0)"40+41+ok ssh_as dami help42+for c in 'passkey \[name\]' 'passkeys \[rm <id>\]' 'sessions \[rm <id>\|--all\]' 'token \[--repo <owner>/<repo>\] \[--ttl 30d\] \[name\]' 'tokens \[rm <id>\]'; do43+ has "$T_LOG" "^ $c "44+done45+for c in passkey passkeys sessions token tokens; do exists "$FORGE_ROOT/git-shell-commands/$c"; done@@ -0,0 +1,64 @@
1+# token, tokens [rm <id>]: a token is printed once, on a line of its own,2+# and only its hash is kept. --repo must name a repo you can read, and says3+# what any command says about one that isn't there; --ttl sets how long it4+# lasts (30d by default, at most 365d). Expired and revoked tokens are gone5+# from the list, and removing a user's keys removes their tokens.6+. "$(dirname "$0")/../lib.sh"7+user dami8+user ana9+repo --private dami/secret10+repo dami/open11+export FORGE_BASE_URL=https://forge.test12+db=$FORGE_ROOT/.forge/identity.db13+14+ssh_as dami token ci >"$T/tok" 2>"$T/err" || fail "token"15+tok=$(cat "$T/tok")16+case $tok in forge_*) ;; *) fail "not a token: $tok" ;; esac17+eq "$(wc -l <"$T/tok" | tr -d ' ')" 118+has "$T/err" '"ci" for every repo dami can read, until 2026-10-01 00:00 UTC'19+has "$T/err" "git clone https://dami:$tok@forge.test/<owner>/<repo>.git"20+if grep -aqF "$tok" "$db" "$db-wal" 2>/dev/null; then fail "the token itself is in identity.db"; fi21+22+ok ssh_as dami token --repo dami/secret --ttl 2d deploy key23+has "$T_LOG" '"deploy key" for dami/secret, until 2026-09-03 00:00 UTC'24+ok ssh_as dami tokens25+has "$T_LOG" '^id +for +made +expires +last used +name$'26+has "$T_LOG" ' dami/secret .* 2026-09-03 00:00 +never +deploy key$'27+has "$T_LOG" ' \(all you read\) .* ci$'28+id=$(awk '$NF == "ci" { print $1 }' "$T_LOG")29+30+# Someone else's private repo is not there, as for any command.31+fails ssh_as ana token --repo dami/secret32+denied=$(output)33+aside dami/secret34+fails ssh_as ana token --repo dami/secret35+back dami/secret36+eq "$denied" "$(output)"37+eq "$denied" "forge: no repo dami/secret"38+ok ssh_as ana token --repo dami/open39+ok ssh_as ana tokens40+has "$T_LOG" ' dami/open '41+lacks "$T_LOG" 'dami/secret|deploy key'42+43+fails_with 'at most 365d' ssh_as dami token --ttl 366d44+fails_with 'like 30d or 12h' ssh_as dami token --ttl 1w45+fails_with 'needs a value' ssh_as dami token --repo46+fails_with 'usage: token' ssh_as dami token --scope x47+48+# Revoked: gone, and only by its owner.49+fails_with "no token $id" ssh_as ana tokens rm "$id"50+ok ssh_as dami tokens rm "$id"51+has "$T_LOG" "revoked token $id \"ci\""52+ok ssh_as dami tokens53+lacks "$T_LOG" ' ci$'54+fails_with "no token $id" ssh_as dami tokens rm "$id"55+56+# Expired: three days on, the 2d token is gone too.57+FORGE_NOW=$((T_NOW + 3 * 86400)) ssh_as dami tokens >"$T_LOG" 2>&1 || fail "tokens"58+has "$T_LOG" '^no tokens'59+60+# A user's tokens go with their keys.61+ok forge user rm ana62+eq "$(FORGE_STATE=$FORGE_ROOT/.forge node -e '63+const { DatabaseSync } = require("node:sqlite");64+console.log(new DatabaseSync(process.argv[1]).prepare("SELECT count(*) AS n FROM tokens WHERE user = ?").get("ana").n)' "$db" 2>/dev/null)" 0