P1 spine: monorepo, shared WS protocol, server daemon
- npm workspaces (shared / server / web), TS strict, project refs - @arboretum/shared: multiplexed WS protocol (JSON control + binary output frames: 1B type + u32le channel), flow-control constants (ACK 256K, HIGH 384K, LOW 128K, lagging 2M), REST types - git-arboretum server: Fastify 5 + node:sqlite (single native dep: node-pty prebuilt), token auth (sha256 at rest, HMAC cookie, global login rate limit + backoff), strict Origin check on /api and /ws, PtyManager (2MiB ring with monotonic offset, resync replay = reset + 256KiB tail, pause/resume only when ALL interactive clients exceed HIGH, observers never throttle, lagging clients resync), WS gateway (attach/stdin/resize/ack, heartbeat 30s), SIGTERM→SIGKILL 5s grace - CLI: arboretum [--port 7317] [--bind 127.0.0.1] — non-loopback bind requires an explicit safety flag - Smoke-tested: login/401/403-origin/spawn bash/kill/grace-SIGKILL all green
This commit is contained in:
124
packages/server/src/auth/service.ts
Normal file
124
packages/server/src/auth/service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||
|
||||
const COOKIE_NAME = 'arb_session';
|
||||
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
|
||||
|
||||
export interface AuthContext {
|
||||
tokenId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private readonly secret: Buffer;
|
||||
|
||||
constructor(private readonly db: Db) {
|
||||
let secretHex = getSetting(db, 'server_secret');
|
||||
if (!secretHex) {
|
||||
secretHex = randomBytes(32).toString('hex');
|
||||
setSetting(db, 'server_secret', secretHex);
|
||||
}
|
||||
this.secret = Buffer.from(secretHex, 'hex');
|
||||
}
|
||||
|
||||
get cookieName(): string {
|
||||
return COOKIE_NAME;
|
||||
}
|
||||
|
||||
/** Crée le token initial au premier démarrage et renvoie sa valeur en clair (affichée une seule fois). */
|
||||
ensureBootstrapToken(): string | null {
|
||||
const row = this.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL')
|
||||
.get() as { n: number };
|
||||
if (row.n > 0) return null;
|
||||
return this.createToken('initial');
|
||||
}
|
||||
|
||||
createToken(label: string): string {
|
||||
const raw = `arb_${randomBytes(24).toString('hex')}`;
|
||||
this.db
|
||||
.prepare('INSERT INTO auth_tokens (id, label, token_hash, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(randomUUID(), label, sha256(raw), new Date().toISOString());
|
||||
return raw;
|
||||
}
|
||||
|
||||
verifyRawToken(raw: string): AuthContext | null {
|
||||
if (typeof raw !== 'string' || raw.length < 8 || raw.length > 128) return null;
|
||||
const row = this.db
|
||||
.prepare('SELECT id, label, token_hash FROM auth_tokens WHERE revoked_at IS NULL AND token_hash = ?')
|
||||
.get(sha256(raw)) as { id: string; label: string; token_hash: string } | undefined;
|
||||
if (!row) return null;
|
||||
// double vérification en temps constant (le lookup par hash est déjà non-oracle, ceinture+bretelles)
|
||||
if (!timingSafeEqual(Buffer.from(row.token_hash), Buffer.from(sha256(raw)))) return null;
|
||||
this.db.prepare('UPDATE auth_tokens SET last_used_at = ? WHERE id = ?').run(new Date().toISOString(), row.id);
|
||||
return { tokenId: row.id, label: row.label };
|
||||
}
|
||||
|
||||
issueCookie(ctx: AuthContext): string {
|
||||
const expires = Date.now() + COOKIE_TTL_MS;
|
||||
const payload = `${ctx.tokenId}.${expires}`;
|
||||
return `${payload}.${this.sign(payload)}`;
|
||||
}
|
||||
|
||||
verifyCookie(value: string | undefined): AuthContext | null {
|
||||
if (!value) return null;
|
||||
const lastDot = value.lastIndexOf('.');
|
||||
if (lastDot <= 0) return null;
|
||||
const payload = value.slice(0, lastDot);
|
||||
const sig = value.slice(lastDot + 1);
|
||||
const expected = this.sign(payload);
|
||||
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||
const [tokenId, expiresStr] = payload.split('.');
|
||||
if (!tokenId || !expiresStr || Number(expiresStr) < Date.now()) return null;
|
||||
const row = this.db
|
||||
.prepare('SELECT id, label FROM auth_tokens WHERE id = ? AND revoked_at IS NULL')
|
||||
.get(tokenId) as { id: string; label: string } | undefined;
|
||||
return row ? { tokenId: row.id, label: row.label } : null;
|
||||
}
|
||||
|
||||
private sign(payload: string): string {
|
||||
return createHmac('sha256', this.secret).update(payload).digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash('sha256').update(s).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit GLOBAL du login (pas par IP : derrière Tailscale Serve toutes les
|
||||
* requêtes arrivent de 127.0.0.1) + backoff exponentiel sur échecs consécutifs.
|
||||
*/
|
||||
export class LoginRateLimiter {
|
||||
private attempts: number[] = [];
|
||||
private consecutiveFailures = 0;
|
||||
private lockedUntil = 0;
|
||||
|
||||
constructor(
|
||||
private readonly maxPerMinute = 5,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
/** null = autorisé ; sinon délai d'attente en ms */
|
||||
check(): number | null {
|
||||
const t = this.now();
|
||||
if (t < this.lockedUntil) return this.lockedUntil - t;
|
||||
this.attempts = this.attempts.filter((a) => t - a < 60_000);
|
||||
if (this.attempts.length >= this.maxPerMinute) return 60_000 - (t - (this.attempts[0] ?? t));
|
||||
this.attempts.push(t);
|
||||
return null;
|
||||
}
|
||||
|
||||
recordFailure(): void {
|
||||
this.consecutiveFailures += 1;
|
||||
if (this.consecutiveFailures >= 3) {
|
||||
const backoff = Math.min(2 ** (this.consecutiveFailures - 3) * 30_000, 30 * 60_000);
|
||||
this.lockedUntil = this.now() + backoff;
|
||||
}
|
||||
}
|
||||
|
||||
recordSuccess(): void {
|
||||
this.consecutiveFailures = 0;
|
||||
this.lockedUntil = 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user