gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit836f644f836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre836f644f/tests/hardening.mjs

11.0 KB

  1. // tests/hardening.mjs — SESSION HARDENING GATE (ticket ident#2, mission 022):
  2. // * a signed-in session has its OWN expiry (store.hl sessionUserTtl), independent of the
  3. // webex session file's rolling idle/maxAge — IDENT_SESSION_TTL_MS on a short-clock server;
  4. // * an OLDER session (no `since`/`epoch` yet, as after the mission-009 migration or from
  5. // before this ticket) is NOT force-signed-out — it is upgraded on its next use;
  6. // * "sign out everywhere" (store.hl signOutEverywhere / home.hl signOutAll) invalidates
  7. // EVERY session of the account at once, the caller's own included, without touching any
  8. // other session file;
  9. // * "sign out of an app" (apps.hl disconnectConnection / appsettings.hl inboxDisconnect)
  10. // forgets one connection: the app gets the same short id on a later login.
  11. // Own servers (.scratch/hardening-gate, wiped at start): ident main :8700 (default TTL),
  12. // ident short :8701 (IDENT_SESSION_TTL_MS=2000). No Chrome — HTTP + the face carrier only,
  13. // same as migration.mjs's non-browser checks.
  14. import { spawn } from 'node:child_process';
  15. import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
  16. import { createHash } from 'node:crypto';
  17. import { dirname, join } from 'node:path';
  18. import { fileURLToPath } from 'node:url';
  19. const APP = join(dirname(fileURLToPath(import.meta.url)), '..');
  20. const G = join(APP, '.scratch/hardening-gate');
  21. const BIN = join(APP, 'bin/hybriel');
  22. const MAIN = 'http://127.0.0.1:8700', SHORT = 'http://127.0.0.1:8701';
  23. const J = JSON.stringify;
  24. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  25. let passed = 0, failed = 0;
  26. const check = (name, ok, detail = '') => {
  27. if (ok) { passed++; console.log(' ok ' + name); }
  28. else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
  29. };
  30. rmSync(G, { recursive: true, force: true });
  31. mkdirSync(join(G, 'main-sess'), { recursive: true });
  32. mkdirSync(join(G, 'short-sess'), { recursive: true });
  33. const procs = [];
  34. function start(name, env) {
  35. let out = '';
  36. const p = spawn(BIN, ['project.hl'], { cwd: APP, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
  37. p.stdout.on('data', (d) => { out += d; }); p.stderr.on('data', (d) => { out += d; });
  38. p.on('exit', () => writeFileSync(join(G, `${name}.log`), out));
  39. procs.push(p);
  40. }
  41. 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' });
  42. 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' });
  43. for (const u of [MAIN, SHORT]) { for (let i = 0; i < 80; i++) { try { await fetch(u + '/', { redirect: 'manual' }); break; } catch {} await sleep(250); } }
  44. // ---- helpers --------------------------------------------------------------------------
  45. const mailCodeOnce = (sink, email) => {
  46. if (!existsSync(sink)) return null;
  47. const lines = readFileSync(sink, 'utf8').split('\n').filter((l) => l.startsWith(email + ' '));
  48. return lines.length ? lines.at(-1).split(' ')[1] : null;
  49. };
  50. // the sink write lands after the /api/code response (apiCode returns before the write is
  51. // guaranteed flushed) — poll briefly instead of reading it once right away
  52. const mailCode = async (sink, email) => {
  53. for (let i = 0; i < 20; i++) {
  54. const c = mailCodeOnce(sink, email);
  55. if (c) return c;
  56. await sleep(100);
  57. }
  58. return null;
  59. };
  60. const cookieOf = (setCookie) => setCookie ? setCookie.split(';')[0] : null;
  61. async function signIn(base, sink, email) {
  62. // /api/code is a plain function route (no session, no Set-Cookie, project.hl apiCode):
  63. // the FIRST cookie is minted by the /__hl/emit call below (the framework's `fresh`
  64. // session path, WebFramework.hl). r1 never carries one — only r2 does.
  65. const r1 = await fetch(base + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ email }) });
  66. const code = await mailCode(sink, email);
  67. 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'] }) });
  68. const cookie = cookieOf(r2.headers.get('set-cookie'));
  69. const v = (await r2.json()).value;
  70. return { cookie, value: v };
  71. }
  72. async function emit(base, cookie, event, payload) {
  73. const r = await fetch(base + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: 1, event, payload }) });
  74. return (await r.json()).value;
  75. }
  76. // ---- 1. a session's own expiry (independent server, TTL 2s) ---------------------------
  77. console.log('# session expiry (IDENT_SESSION_TTL_MS)');
  78. {
  79. const { cookie } = await signIn(SHORT, join(G, 'short-mail.txt'), '[email protected]');
  80. let v = await emit(SHORT, cookie, 'addIdentity', [{}]);
  81. check('right after signing in: an authenticated face works', v && v.error == null, J(v));
  82. await sleep(2600);
  83. v = await emit(SHORT, cookie, 'addIdentity', [{}]);
  84. check('2.6s later (TTL 2s): the same cookie is signed out', v && v.error === 'you are not signed in', J(v));
  85. }
  86. // ---- 2. an older session (no since/epoch) is upgraded, not force-signed-out -----------
  87. console.log('# older session (pre-hardening shape) stays signed in');
  88. {
  89. const { value } = await signIn(MAIN, join(G, 'main-mail.txt'), '[email protected]');
  90. const accountId = value.account.id;
  91. const sid = 'f'.repeat(32);
  92. const file = join(G, 'main-sess', createHash('sha256').update(sid).digest('hex'));
  93. writeFileSync(file, J({ created: Date.now() - 999999, data: {}, id: sid, seen: Date.now() - 999999, user: { id: accountId } }), { mode: 0o600 });
  94. const v = await emit(MAIN, 'identsid=' + sid, 'addIdentity', [{}]);
  95. 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));
  96. const after = JSON.parse(readFileSync(file, 'utf8'));
  97. check('…and gets upgraded on disk (since + epoch stamped)', typeof after.user.since === 'number' && typeof after.user.epoch === 'number', J(after.user));
  98. }
  99. // ---- 3. sign out everywhere --------------------------------------------------------------
  100. console.log('# sign out everywhere');
  101. {
  102. const email = '[email protected]';
  103. const a = await signIn(MAIN, join(G, 'main-mail.txt'), email);
  104. let v = await emit(MAIN, a.cookie, 'addIdentity', [{}]);
  105. check('device A: signed in', v && v.error == null, J(v));
  106. await fetch(MAIN + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ email }) });
  107. const code = await mailCode(join(G, "main-mail.txt"), email);
  108. 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'] }) });
  109. const cookieB = cookieOf(r2.headers.get('set-cookie'));
  110. v = (await r2.json()).value;
  111. check('device B: same account, signed in too', v && v.error == null, J(v));
  112. 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; });
  113. check('before: the account has 2 session files', mine().length === 2, J(mine()));
  114. v = await emit(MAIN, cookieB, 'signOutAll', []);
  115. check('device B calls "sign out everywhere"', v && v.ok === true, J(v));
  116. const left = mine();
  117. check('every session file of the account is deleted at once', left.length === 0, J(left));
  118. v = await emit(MAIN, a.cookie, 'addIdentity', [{}]);
  119. check('device A is now signed out too', v && v.error === 'you are not signed in', J(v));
  120. v = await emit(MAIN, cookieB, 'addIdentity', [{}]);
  121. check('device B (the caller) is signed out too', v && v.error === 'you are not signed in', J(v));
  122. const again = await signIn(MAIN, join(G, 'main-mail.txt'), email);
  123. check('the account can still sign in again afterwards', again.value && again.value.error == null, J(again.value));
  124. }
  125. // ---- 4. sign out of one app (disconnect) --------------------------------------------------
  126. console.log('# sign out of one app');
  127. {
  128. const { cookie, value } = await signIn(MAIN, join(G, 'main-mail.txt'), '[email protected]');
  129. const identityId = value.identities[0].id;
  130. const created = await emit(MAIN, cookie, 'appCreate', [{ name: 'Hardening test app', origins: ['http://127.0.0.1:8703'] }]);
  131. const { apiKey } = created.app; const secret = created.secret;
  132. const login1 = await fetch(MAIN + `/login?key=${apiKey}&return=${encodeURIComponent('http://127.0.0.1:8703/cb')}`, { redirect: 'manual' });
  133. const rid1 = login1.headers.get('location').split('/').pop();
  134. const choice1 = await emit(MAIN, cookie, 'chooseIdentity', [rid1, identityId]);
  135. const code1 = new URL(choice1.url).searchParams.get('ident_code');
  136. const ex1 = await (await fetch(MAIN + '/api/exchange', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ key: apiKey, secret, code: code1 }) })).json();
  137. check('the app first exchanges an id for the identity', ex1.identity && /^[2-9a-hj-km-np-z]{5}$/.test(ex1.identity), J(ex1));
  138. // find the connection id the same way the inbox page would (a fresh dump on a COPY)
  139. const cpDir = join(G, 'conn-dump'); mkdirSync(cpDir, { recursive: true });
  140. const { cpSync, execFileSync } = await import('node:fs').then(async (fs) => ({ cpSync: fs.cpSync, execFileSync: (await import('node:child_process')).execFileSync }));
  141. cpSync(join(G, 'main-store'), cpDir, { recursive: true });
  142. const dump = JSON.parse(execFileSync(BIN, [join(APP, 'tools/dump-store.hl')], { cwd: APP, env: { ...process.env, IDENT_STORAGE: cpDir }, encoding: 'utf8' }));
  143. const conn = dump.connections.find((c) => c.identity === identityId);
  144. check('the connection for that app+identity exists', !!conn, J(dump.connections));
  145. const disc = await emit(MAIN, cookie, 'inboxDisconnect', [conn.id]);
  146. check('signing out of the app (disconnect) succeeds', disc && disc.disconnected === conn.id, J(disc));
  147. const disc2 = await emit(MAIN, cookie, 'inboxDisconnect', [conn.id]);
  148. check('…and cannot be repeated (the connection is gone)', disc2 && disc2.error != null, J(disc2));
  149. const login2 = await fetch(MAIN + `/login?key=${apiKey}&return=${encodeURIComponent('http://127.0.0.1:8703/cb')}`, { redirect: 'manual' });
  150. const rid2 = login2.headers.get('location').split('/').pop();
  151. const choice2 = await emit(MAIN, cookie, 'chooseIdentity', [rid2, identityId]);
  152. const code2 = new URL(choice2.url).searchParams.get('ident_code');
  153. const ex2 = await (await fetch(MAIN + '/api/exchange', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ key: apiKey, secret, code: code2 }) })).json();
  154. 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 }));
  155. }
  156. for (const p of procs) p.kill();
  157. console.log(`\n${passed} passed, ${failed} failed`);
  158. process.exit(failed ? 1 : 0);

Branches

Latest commits

  • 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