ident
All repositories: gitoria
11.0 KB
// tests/hardening.mjs — SESSION HARDENING GATE (ticket ident#2, mission 022):// * a signed-in session has its OWN expiry (store.hl sessionUserTtl), independent of the// webex session file's rolling idle/maxAge — IDENT_SESSION_TTL_MS on a short-clock server;// * an OLDER session (no `since`/`epoch` yet, as after the mission-009 migration or from// before this ticket) is NOT force-signed-out — it is upgraded on its next use;// * "sign out everywhere" (store.hl signOutEverywhere / home.hl signOutAll) invalidates// EVERY session of the account at once, the caller's own included, without touching any// other session file;// * "sign out of an app" (apps.hl disconnectConnection / appsettings.hl inboxDisconnect)// forgets one connection: the app gets the same short id on a later login.// Own servers (.scratch/hardening-gate, wiped at start): ident main :8700 (default TTL),// ident short :8701 (IDENT_SESSION_TTL_MS=2000). No Chrome — HTTP + the face carrier only,// same as migration.mjs's non-browser checks.import { spawn } from 'node:child_process';import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync, readdirSync } from 'node:fs';import { createHash } from 'node:crypto';import { dirname, join } from 'node:path';import { fileURLToPath } from 'node:url';const APP = join(dirname(fileURLToPath(import.meta.url)), '..');const G = join(APP, '.scratch/hardening-gate');const BIN = join(APP, 'bin/hybriel');const MAIN = 'http://127.0.0.1:8700', SHORT = 'http://127.0.0.1:8701';const J = JSON.stringify;const sleep = (ms) => new Promise((r) => setTimeout(r, ms));let passed = 0, failed = 0;const check = (name, ok, detail = '') => {if (ok) { passed++; console.log(' ok ' + name); }else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }};rmSync(G, { recursive: true, force: true });mkdirSync(join(G, 'main-sess'), { recursive: true });mkdirSync(join(G, 'short-sess'), { recursive: true });const procs = [];function start(name, env) {let out = '';const p = spawn(BIN, ['project.hl'], { cwd: APP, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });p.stdout.on('data', (d) => { out += d; }); p.stderr.on('data', (d) => { out += d; });p.on('exit', () => writeFileSync(join(G, `${name}.log`), out));procs.push(p);}start('main', { IDENT_PORT: '8700', IDENT_STORAGE: join(G, 'main-store'), IDENT_SESSIONS: join(G, 'main-sess'), IDENT_MAIL_SINK: join(G, 'main-mail.txt'), IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000' });start('short', { IDENT_PORT: '8701', IDENT_STORAGE: join(G, 'short-store'), IDENT_SESSIONS: join(G, 'short-sess'), IDENT_MAIL_SINK: join(G, 'short-mail.txt'), IDENT_SESSION_TTL_MS: '2000', IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000' });for (const u of [MAIN, SHORT]) { for (let i = 0; i < 80; i++) { try { await fetch(u + '/', { redirect: 'manual' }); break; } catch {} await sleep(250); } }// ---- helpers --------------------------------------------------------------------------const mailCodeOnce = (sink, email) => {if (!existsSync(sink)) return null;const lines = readFileSync(sink, 'utf8').split('\n').filter((l) => l.startsWith(email + ' '));return lines.length ? lines.at(-1).split(' ')[1] : null;};// the sink write lands after the /api/code response (apiCode returns before the write is// guaranteed flushed) — poll briefly instead of reading it once right awayconst mailCode = async (sink, email) => {for (let i = 0; i < 20; i++) {const c = mailCodeOnce(sink, email);if (c) return c;await sleep(100);}return null;};const cookieOf = (setCookie) => setCookie ? setCookie.split(';')[0] : null;async function signIn(base, sink, email) {// /api/code is a plain function route (no session, no Set-Cookie, project.hl apiCode):// the FIRST cookie is minted by the /__hl/emit call below (the framework's `fresh`// session path, WebFramework.hl). r1 never carries one — only r2 does.const r1 = await fetch(base + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ email }) });const code = await mailCode(sink, email);const r2 = await fetch(base + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ t: 'emit', i: 1, event: 'verifyCode', payload: [email, code, 'UTC'] }) });const cookie = cookieOf(r2.headers.get('set-cookie'));const v = (await r2.json()).value;return { cookie, value: v };}async function emit(base, cookie, event, payload) {const r = await fetch(base + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: 1, event, payload }) });return (await r.json()).value;}// ---- 1. a session's own expiry (independent server, TTL 2s) ---------------------------console.log('# session expiry (IDENT_SESSION_TTL_MS)');{const { cookie } = await signIn(SHORT, join(G, 'short-mail.txt'), '[email protected]');let v = await emit(SHORT, cookie, 'addIdentity', [{}]);check('right after signing in: an authenticated face works', v && v.error == null, J(v));await sleep(2600);v = await emit(SHORT, cookie, 'addIdentity', [{}]);check('2.6s later (TTL 2s): the same cookie is signed out', v && v.error === 'you are not signed in', J(v));}// ---- 2. an older session (no since/epoch) is upgraded, not force-signed-out -----------console.log('# older session (pre-hardening shape) stays signed in');{const { value } = await signIn(MAIN, join(G, 'main-mail.txt'), '[email protected]');const accountId = value.account.id;const sid = 'f'.repeat(32);const file = join(G, 'main-sess', createHash('sha256').update(sid).digest('hex'));writeFileSync(file, J({ created: Date.now() - 999999, data: {}, id: sid, seen: Date.now() - 999999, user: { id: accountId } }), { mode: 0o600 });const v = await emit(MAIN, 'identsid=' + sid, 'addIdentity', [{}]);check('an old-shape session (user: {id} only) still signs its account in', v && v.error == null && v.identities && v.identities.length === 2, J(v));const after = JSON.parse(readFileSync(file, 'utf8'));check('…and gets upgraded on disk (since + epoch stamped)', typeof after.user.since === 'number' && typeof after.user.epoch === 'number', J(after.user));}// ---- 3. sign out everywhere --------------------------------------------------------------console.log('# sign out everywhere');{const email = '[email protected]';const a = await signIn(MAIN, join(G, 'main-mail.txt'), email);let v = await emit(MAIN, a.cookie, 'addIdentity', [{}]);check('device A: signed in', v && v.error == null, J(v));await fetch(MAIN + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ email }) });const code = await mailCode(join(G, "main-mail.txt"), email);const r2 = await fetch(MAIN + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ t: 'emit', i: 1, event: 'verifyCode', payload: [email, code, 'UTC'] }) });const cookieB = cookieOf(r2.headers.get('set-cookie'));v = (await r2.json()).value;check('device B: same account, signed in too', v && v.error == null, J(v));const mine = () => readdirSync(join(G, 'main-sess')).filter((f) => { const r = JSON.parse(readFileSync(join(G, 'main-sess', f), 'utf8')); return r.user != null && r.user.id === a.value.account.id; });check('before: the account has 2 session files', mine().length === 2, J(mine()));v = await emit(MAIN, cookieB, 'signOutAll', []);check('device B calls "sign out everywhere"', v && v.ok === true, J(v));const left = mine();check('every session file of the account is deleted at once', left.length === 0, J(left));v = await emit(MAIN, a.cookie, 'addIdentity', [{}]);check('device A is now signed out too', v && v.error === 'you are not signed in', J(v));v = await emit(MAIN, cookieB, 'addIdentity', [{}]);check('device B (the caller) is signed out too', v && v.error === 'you are not signed in', J(v));const again = await signIn(MAIN, join(G, 'main-mail.txt'), email);check('the account can still sign in again afterwards', again.value && again.value.error == null, J(again.value));}// ---- 4. sign out of one app (disconnect) --------------------------------------------------console.log('# sign out of one app');{const { cookie, value } = await signIn(MAIN, join(G, 'main-mail.txt'), '[email protected]');const identityId = value.identities[0].id;const created = await emit(MAIN, cookie, 'appCreate', [{ name: 'Hardening test app', origins: ['http://127.0.0.1:8703'] }]);const { apiKey } = created.app; const secret = created.secret;const login1 = await fetch(MAIN + `/login?key=${apiKey}&return=${encodeURIComponent('http://127.0.0.1:8703/cb')}`, { redirect: 'manual' });const rid1 = login1.headers.get('location').split('/').pop();const choice1 = await emit(MAIN, cookie, 'chooseIdentity', [rid1, identityId]);const code1 = new URL(choice1.url).searchParams.get('ident_code');const ex1 = await (await fetch(MAIN + '/api/exchange', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ key: apiKey, secret, code: code1 }) })).json();check('the app first exchanges an id for the identity', ex1.identity && /^[2-9a-hj-km-np-z]{5}$/.test(ex1.identity), J(ex1));// find the connection id the same way the inbox page would (a fresh dump on a COPY)const cpDir = join(G, 'conn-dump'); mkdirSync(cpDir, { recursive: true });const { cpSync, execFileSync } = await import('node:fs').then(async (fs) => ({ cpSync: fs.cpSync, execFileSync: (await import('node:child_process')).execFileSync }));cpSync(join(G, 'main-store'), cpDir, { recursive: true });const dump = JSON.parse(execFileSync(BIN, [join(APP, 'tools/dump-store.hl')], { cwd: APP, env: { ...process.env, IDENT_STORAGE: cpDir }, encoding: 'utf8' }));const conn = dump.connections.find((c) => c.identity === identityId);check('the connection for that app+identity exists', !!conn, J(dump.connections));const disc = await emit(MAIN, cookie, 'inboxDisconnect', [conn.id]);check('signing out of the app (disconnect) succeeds', disc && disc.disconnected === conn.id, J(disc));const disc2 = await emit(MAIN, cookie, 'inboxDisconnect', [conn.id]);check('…and cannot be repeated (the connection is gone)', disc2 && disc2.error != null, J(disc2));const login2 = await fetch(MAIN + `/login?key=${apiKey}&return=${encodeURIComponent('http://127.0.0.1:8703/cb')}`, { redirect: 'manual' });const rid2 = login2.headers.get('location').split('/').pop();const choice2 = await emit(MAIN, cookie, 'chooseIdentity', [rid2, identityId]);const code2 = new URL(choice2.url).searchParams.get('ident_code');const ex2 = await (await fetch(MAIN + '/api/exchange', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ key: apiKey, secret, code: code2 }) })).json();check('a later login gets the SAME short id again (it never changes)', ex2.identity && ex2.identity === ex1.identity, J({ old: ex1.identity, new: ex2.identity }));}for (const p of procs) p.kill();console.log(`\n${passed} passed, ${failed} failed`);process.exit(failed ? 1 : 0);
Branches
- mainmain branch
Latest commits
- 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
- f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
- ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
- a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
- 98226b41antcolony#40: mission references point to the moved missionsmre
- ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
- 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
- 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
- 81b15b7bState of 2026-09-27, before the move to gitoriamre