toiljs 0.0.117 → 0.0.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import { FactorRepository } from './FactorSettings';
2
+ import { SessionEpochs } from './SessionEpochs';
1
3
  import { Response, RouteContext } from 'toiljs/server/runtime';
2
4
  import { DataReader, DataWriter } from 'data';
3
5
 
@@ -500,6 +502,8 @@ class Challenge {
500
502
  nonce: Uint8Array = new Uint8Array(0);
501
503
  iat: u64 = 0;
502
504
  exp: u64 = 0;
505
+ sessionEpoch: u64 = 0;
506
+ sessionVersion: u8 = 0;
503
507
  }
504
508
 
505
509
  /** The lookup key for a login 2FA challenge: 16 random bytes minted per login.
@@ -531,6 +535,9 @@ class TwoFaChallenge {
531
535
  exp: u64 = 0;
532
536
  attempts: u32 = 0;
533
537
  targetMethod: u8 = 0;
538
+ sessionEpoch: u64 = 0;
539
+ sessionVersion: u8 = 0;
540
+ settingsRevision: u64 = 0;
534
541
  }
535
542
 
536
543
  @database
@@ -729,6 +736,9 @@ class Auth {
729
736
  c.nonce = nonce;
730
737
  c.iat = iat;
731
738
  c.exp = exp;
739
+ const account = AuthDb.accounts.get(new Username(h, username))!;
740
+ c.sessionEpoch = SessionEpochs.current(__toilEncodeAuthUser(this.stableUserId(ctx, h, username, account), username));
741
+ c.sessionVersion = 2;
732
742
  AuthDb.challenges.create(new ChallengeId(h, cid), c);
733
743
  }
734
744
 
@@ -763,6 +773,7 @@ class Auth {
763
773
  // The challenge is realm-keyed, so one minted on another domain misses.
764
774
  const ch = AuthDb.challenges.getDelete(new ChallengeId(h, cid));
765
775
  if (ch == null) return fail();
776
+ if (ch.sessionVersion != 2) return fail();
766
777
  if (nowSecs() >= ch.exp) return fail();
767
778
 
768
779
  // 2. Rebuild the message from OUR stored values + the client's ct (and
@@ -810,11 +821,12 @@ class Auth {
810
821
  // never resetTokens/confirmTokens). This runs AFTER the signature
811
822
  // verify above, so it is not an oracle, and the login `Challenge` was
812
823
  // already consumed (getDelete) at the top of this handler.
813
- if (acct.twoFactorMethod != TWOFA_NONE) {
824
+ const factorMethod = FactorRepository.read(h, ch.username, acct.twoFactorMethod).method;
825
+ if (factorMethod != TWOFA_NONE) {
814
826
  const raw = randomBytes(16); // the twoFaId the client echoes to /2fa/verify
815
827
  const codeHash = this.twoFaDeliver(
816
828
  h,
817
- acct.twoFactorMethod,
829
+ factorMethod,
818
830
  ch.username,
819
831
  acct.email,
820
832
  'Your login code',
@@ -823,10 +835,12 @@ class Auth {
823
835
  if (codeHash.length == 0) return fail(); // unsupported/misconfigured method
824
836
  const c2 = new TwoFaChallenge();
825
837
  c2.username = ch.username;
826
- c2.method = acct.twoFactorMethod;
838
+ c2.method = factorMethod;
827
839
  c2.codeHash = codeHash;
828
840
  c2.exp = nowSecs() + TWOFA_TTL_SECS;
829
841
  c2.attempts = 0;
842
+ c2.sessionEpoch = ch.sessionEpoch;
843
+ c2.sessionVersion = 2;
830
844
  c2.targetMethod = TWOFA_NONE; // a LOGIN challenge changes no method on success
831
845
  AuthDb.twoFaLogins.create(new TwoFaId(h, raw), c2);
832
846
 
@@ -848,7 +862,7 @@ class Auth {
848
862
  w.writeBytes(userData); // opaque session token (the readable user payload)
849
863
  w.writeBytes(confirm);
850
864
  const resp = Response.bytes(w.toBytes());
851
- resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
865
+ resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS, ch.sessionEpoch));
852
866
  resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
853
867
  return resp;
854
868
  }
@@ -951,9 +965,11 @@ class Auth {
951
965
  // Overwrite ONLY the login verifier. Salt/params are deterministic
952
966
  // constants (unchanged); a successful reset also implies email ownership,
953
967
  // so confirm the account while we are here.
968
+ const stableId = this.stableUserId(ctx, h, rec.username, acct);
954
969
  acct.publicKey = pk;
955
970
  acct.emailConfirmed = true;
956
971
  AuthDb.accounts.patch(new Username(h, rec.username), acct);
972
+ AuthService.revokeUserSessions(__toilEncodeAuthUser(stableId, rec.username));
957
973
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
958
974
  }
959
975
 
@@ -986,6 +1002,18 @@ class Auth {
986
1002
  @auth
987
1003
  @post('/logout')
988
1004
  public logout(_ctx: RouteContext): Response {
1005
+ AuthService.revokeSession();
1006
+ const resp = Response.text('bye\n', 200);
1007
+ resp.setCookie(AuthService.clearSession());
1008
+ resp.setCookie(AuthService.clearUserCookie());
1009
+ return resp;
1010
+ }
1011
+
1012
+ /** Revoke all sessions, including a copied cookie and pending pre-revocation logins. */
1013
+ @auth
1014
+ @post('/logout-all')
1015
+ public logoutAll(_ctx: RouteContext): Response {
1016
+ AuthService.revokeUserSessions(AuthService.getSessionBytes()!);
989
1017
  const resp = Response.text('bye\n', 200);
990
1018
  resp.setCookie(AuthService.clearSession());
991
1019
  resp.setCookie(AuthService.clearUserCookie());
@@ -1029,6 +1057,7 @@ class Auth {
1029
1057
  // would otherwise lock the user out) until the attempt cap is reached.
1030
1058
  const ch = AuthDb.twoFaLogins.get(key);
1031
1059
  if (ch == null) return fail();
1060
+ if (ch.sessionVersion != 2) return fail(); // reject pre-upgrade pending logins
1032
1061
  if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
1033
1062
  AuthDb.twoFaLogins.delete(key); // burn an expired / exhausted challenge
1034
1063
  return fail();
@@ -1043,7 +1072,8 @@ class Auth {
1043
1072
  }
1044
1073
 
1045
1074
  // SUCCESS: consume-once, then mint the session EXACTLY like login/finish.
1046
- AuthDb.twoFaLogins.getDelete(key);
1075
+ const consumed = AuthDb.twoFaLogins.getDelete(key);
1076
+ if (consumed == null) return fail();
1047
1077
  const acct = AuthDb.accounts.get(new Username(h, ch.username));
1048
1078
  if (acct == null) return fail();
1049
1079
  const toilUserId = this.stableUserId(ctx, h, ch.username, acct);
@@ -1052,7 +1082,7 @@ class Auth {
1052
1082
  w.writeU8(ST_OK);
1053
1083
  w.writeBytes(userData);
1054
1084
  const resp = Response.bytes(w.toBytes());
1055
- resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
1085
+ resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS, ch.sessionEpoch));
1056
1086
  resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
1057
1087
  return resp;
1058
1088
  }
@@ -1078,8 +1108,9 @@ class Auth {
1078
1108
  const acct = AuthDb.accounts.get(new Username(h, u.username));
1079
1109
  if (acct == null) return fail();
1080
1110
 
1111
+ const settings = FactorRepository.read(h, u.username, acct.twoFactorMethod);
1081
1112
  // Disabling when 2FA is already off: nothing to prove; generic ST_OK.
1082
- if (targetMethod == TWOFA_NONE && acct.twoFactorMethod == TWOFA_NONE) {
1113
+ if (targetMethod == TWOFA_NONE && settings.method == TWOFA_NONE) {
1083
1114
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
1084
1115
  }
1085
1116
 
@@ -1087,7 +1118,7 @@ class Auth {
1087
1118
  // ENABLE (targetMethod != NONE): the NEW method (prove you control it).
1088
1119
  // DISABLE (targetMethod == NONE): the CURRENT method (prove you still
1089
1120
  // hold the existing factor before it is removed).
1090
- const deliverMethod: u8 = targetMethod != TWOFA_NONE ? targetMethod : acct.twoFactorMethod;
1121
+ const deliverMethod: u8 = targetMethod != TWOFA_NONE ? targetMethod : settings.method;
1091
1122
  // Action phrase for the 2FA email body (the `{action}` token): enabling proves the
1092
1123
  // new factor, disabling proves the current one before it is removed.
1093
1124
  const twoFaAction: string =
@@ -1106,6 +1137,9 @@ class Auth {
1106
1137
 
1107
1138
  const c = new TwoFaChallenge();
1108
1139
  c.username = u.username;
1140
+ c.settingsRevision = settings.revision;
1141
+ c.sessionVersion = 2;
1142
+ c.sessionEpoch = SessionEpochs.current(AuthService.getSessionBytes()!);
1109
1143
  c.method = deliverMethod;
1110
1144
  c.codeHash = codeHash;
1111
1145
  c.exp = nowSecs() + TWOFA_TTL_SECS;
@@ -1148,7 +1182,7 @@ class Auth {
1148
1182
 
1149
1183
  const key = new Username(h, u.username);
1150
1184
  const ch = AuthDb.twoFaSetup.get(key);
1151
- if (ch == null) return fail();
1185
+ if (ch == null || ch.sessionVersion != 2 || !SessionEpochs.allowed(AuthService.getSessionBytes()!, ch.sessionEpoch)) return fail();
1152
1186
  if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
1153
1187
  AuthDb.twoFaSetup.delete(key);
1154
1188
  return fail();
@@ -1161,12 +1195,17 @@ class Auth {
1161
1195
  }
1162
1196
 
1163
1197
  // SUCCESS: consume-once + apply the method change.
1164
- AuthDb.twoFaSetup.getDelete(key);
1198
+ const consumed = AuthDb.twoFaSetup.getDelete(key);
1199
+ if (consumed == null || nowSecs() >= consumed.exp || !this.twoFaVerifyCode(h, consumed.username, code, consumed)) return fail();
1165
1200
  const acct = AuthDb.accounts.get(key);
1166
1201
  if (acct == null) return fail();
1167
- acct.twoFactorMethod = ch.targetMethod;
1168
- AuthDb.accounts.patch(key, acct);
1169
- return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
1202
+ if (!FactorRepository.save(h, u.username, consumed.targetMethod, consumed.settingsRevision)) return fail();
1203
+ const userData = __toilEncodeAuthUser(this.stableUserId(_ctx, h, u.username, acct), u.username);
1204
+ AuthService.revokeUserSessions(userData);
1205
+ const response = Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
1206
+ response.setCookie(AuthService.clearSession());
1207
+ response.setCookie(AuthService.clearUserCookie());
1208
+ return response;
1170
1209
  }
1171
1210
 
1172
1211
  /** GET /auth/2fa/status (@auth) resp: u8(method) (0 = off, 1 = email, ...)
@@ -1179,7 +1218,7 @@ class Auth {
1179
1218
  const h = realm(_ctx);
1180
1219
  const acct = AuthDb.accounts.get(new Username(h, u.username));
1181
1220
  if (acct == null) return fail();
1182
- return Response.bytes(new DataWriter().writeU8(acct.twoFactorMethod).toBytes());
1221
+ return Response.bytes(new DataWriter().writeU8(FactorRepository.read(h, u.username, acct.twoFactorMethod).method).toBytes());
1183
1222
  }
1184
1223
 
1185
1224
  /**
@@ -0,0 +1,41 @@
1
+ @data
2
+ class FactorKey {
3
+ host: string = '';
4
+ username: string = '';
5
+ constructor(host: string = '', username: string = '') { this.host = host; this.username = username; }
6
+ }
7
+ @data
8
+ class FactorRevision {
9
+ host: string = '';
10
+ username: string = '';
11
+ revision: u64 = 0;
12
+ constructor(host: string = '', username: string = '', revision: u64 = 0) { this.host = host; this.username = username; this.revision = revision; }
13
+ }
14
+ @data
15
+ export class FactorSettings {
16
+ method: u8 = 0;
17
+ revision: u64 = 0;
18
+ }
19
+ @database
20
+ class FactorDb {
21
+ @collection static heads: Documents<FactorKey, FactorSettings>;
22
+ @collection static revisions: Unique<FactorRevision, FactorSettings>;
23
+ }
24
+ export class FactorRepository {
25
+ public static read(host: string, username: string, legacy: u8): FactorSettings {
26
+ let settings = FactorDb.heads.get(new FactorKey(host, username));
27
+ if (settings == null) { settings = new FactorSettings(); settings.method = legacy; }
28
+ for (let i: i32 = 0; i < 64; i++) {
29
+ const next = FactorDb.revisions.lookup(new FactorRevision(host, username, settings.revision + 1));
30
+ if (next == null) return settings;
31
+ settings = next;
32
+ }
33
+ throw new Error('Two-factor settings unavailable');
34
+ }
35
+ public static save(host: string, username: string, method: u8, expected: u64): bool {
36
+ const settings = new FactorSettings(); settings.method = method; settings.revision = expected + 1;
37
+ if (!FactorDb.revisions.claim(new FactorRevision(host, username, settings.revision), settings).claimed) return false;
38
+ FactorDb.heads.upsert(new FactorKey(host, username), settings);
39
+ return true;
40
+ }
41
+ }
@@ -0,0 +1,58 @@
1
+ import { DataReader } from 'data';
2
+ @data
3
+ class SessionUserKey {
4
+ id: string = '';
5
+ constructor(id: string = '') { this.id = id; }
6
+ }
7
+ @data
8
+ class SessionEpochKey {
9
+ id: string = '';
10
+ revision: u64 = 0;
11
+ constructor(id: string = '', revision: u64 = 0) { this.id = id; this.revision = revision; }
12
+ }
13
+ @data
14
+ class SessionEpoch {
15
+ revision: u64 = 0;
16
+ }
17
+ @database
18
+ class SessionEpochDb {
19
+ @collection static heads: Documents<SessionUserKey, SessionEpoch>;
20
+ @collection static revisions: Unique<SessionEpochKey, SessionEpoch>;
21
+ }
22
+ /** Immutable per-user generations fence sessions and in-flight login challenges without clock assumptions. */
23
+ export class SessionEpochs {
24
+ private static key(userData: Uint8Array): string {
25
+ const reader = new DataReader(userData);
26
+ reader.readU32(); // @data message-boundary identifier
27
+ const id = reader.readBytes();
28
+ if (!reader.ok || id.length != 32) throw new Error('Invalid session identity');
29
+ return crypto.toHex(id);
30
+ }
31
+ private static read(id: string): SessionEpoch {
32
+ let epoch = SessionEpochDb.heads.get(new SessionUserKey(id));
33
+ if (epoch == null) epoch = new SessionEpoch();
34
+ for (let i: i32 = 0; i < 64; i++) {
35
+ const next = SessionEpochDb.revisions.lookup(new SessionEpochKey(id, epoch.revision + 1));
36
+ if (next == null) return epoch;
37
+ epoch = next;
38
+ }
39
+ throw new Error('Session state unavailable');
40
+ }
41
+ public static current(userData: Uint8Array): u64 {
42
+ return SessionEpochs.read(SessionEpochs.key(userData)).revision;
43
+ }
44
+ public static allowed(userData: Uint8Array, generation: u64): bool {
45
+ return generation == SessionEpochs.current(userData);
46
+ }
47
+ public static revoke(userData: Uint8Array): void {
48
+ const id = SessionEpochs.key(userData);
49
+ for (let i: i32 = 0; i < 3; i++) {
50
+ const epoch = SessionEpochs.read(id);
51
+ epoch.revision += 1;
52
+ if (!SessionEpochDb.revisions.claim(new SessionEpochKey(id, epoch.revision), epoch).claimed) continue;
53
+ SessionEpochDb.heads.upsert(new SessionUserKey(id), epoch);
54
+ return;
55
+ }
56
+ throw new Error('Session revocation conflicted; retry');
57
+ }
58
+ }
@@ -0,0 +1,13 @@
1
+ @data
2
+ export class SessionDigest {
3
+ digest: string = '';
4
+ constructor(digest: string = '') { this.digest = digest; }
5
+ }
6
+ @data
7
+ export class RevokedSession {
8
+ revoked: bool = true;
9
+ }
10
+ @database
11
+ export class SessionRevocations {
12
+ @collection static sessions: Unique<SessionDigest, RevokedSession>;
13
+ }
@@ -10,6 +10,8 @@
10
10
  // and the toiljs dev-server mock).
11
11
 
12
12
  import { DataWriter, DataReader } from 'data';
13
+ import { SessionEpochs } from 'toiljs/server/auth/SessionEpochs';
14
+ import { SessionRevocations, SessionDigest, RevokedSession } from 'toiljs/server/auth/SessionRevocations';
13
15
  import { HmacImportParams, HmacParams, ALG_SHA_256, FMT_RAW, USAGE_SIGN, USAGE_VERIFY } from 'crypto';
14
16
 
15
17
  import {
@@ -260,7 +262,7 @@ export namespace AuthService {
260
262
  }
261
263
 
262
264
  /** Session payload format version (first byte of the sealed payload). */
263
- const SESSION_VERSION: u8 = 1;
265
+ const SESSION_VERSION: u8 = 2;
264
266
 
265
267
  /** Default session lifetime if `mintSession` is called without a ttl. */
266
268
  export const DEFAULT_SESSION_TTL_SECS: u64 = 86400; // 24h
@@ -290,6 +292,7 @@ export namespace AuthService {
290
292
  sessionCookieName(__reqIsSecure()),
291
293
  );
292
294
  if (sealed == null) return null;
295
+ if (SessionRevocations.sessions.lookup(new SessionDigest(crypto.toHex(crypto.sha256Text(sealed)))) != null) return null;
293
296
 
294
297
  const payload = base64UrlDecode(sealed);
295
298
  if (payload == null) return null;
@@ -299,9 +302,13 @@ export namespace AuthService {
299
302
  r.readU64(); // iat (unused on read)
300
303
  const exp = r.readU64();
301
304
  const userBytes = r.readBytes();
305
+ const nonce = r.readBytes();
306
+ const generation = r.readU64();
307
+ if (nonce.length != 32) return null;
302
308
  if (!r.ok) return null; // truncated/malformed
303
309
 
304
310
  if (Time.nowSeconds() >= exp) return null; // expired
311
+ if (!SessionEpochs.allowed(userBytes, generation)) return null;
305
312
 
306
313
  return userBytes;
307
314
  }
@@ -367,13 +374,17 @@ export namespace AuthService {
367
374
  * `Response.setCookie(...)`. HMAC-signed, HttpOnly, Secure, SameSite=Lax,
368
375
  * `__Host-` scoped. The value stays readable but cannot be forged or moved.
369
376
  */
370
- export function mintSession(userData: Uint8Array, ttlSecs: u64 = DEFAULT_SESSION_TTL_SECS): Cookie {
377
+ export function mintSession(userData: Uint8Array, ttlSecs: u64 = DEFAULT_SESSION_TTL_SECS, generation: u64 = u64.MAX_VALUE): Cookie {
371
378
  const now = Time.nowSeconds();
372
379
  const w = new DataWriter();
373
380
  w.writeU8(SESSION_VERSION);
374
381
  w.writeU64(now);
375
382
  w.writeU64(now + ttlSecs);
376
383
  w.writeBytes(userData);
384
+ const nonce = new Uint8Array(32);
385
+ crypto.getRandomValues(nonce);
386
+ w.writeBytes(nonce);
387
+ w.writeU64(generation != u64.MAX_VALUE ? generation : SessionEpochs.current(userData));
377
388
 
378
389
  const secure = __reqIsSecure();
379
390
  let cookie = Cookie.create(SESSION_BASE, base64UrlEncode(w.toBytes()))
@@ -384,6 +395,20 @@ export namespace AuthService {
384
395
  return SecureCookies.signed(__resolveSessionSecret()).seal(cookie);
385
396
  }
386
397
 
398
+ /** Invalidate all browser sessions authorized under the prior session generation. */
399
+ export function revokeUserSessions(userData: Uint8Array): void {
400
+ SessionEpochs.revoke(userData);
401
+ }
402
+
403
+ /** Revoke the verified signed payload, including its unpredictable per-login nonce. */
404
+ export function revokeSession(): void {
405
+ if (!hasSession()) return;
406
+ const req = Server.currentRequest!;
407
+ const sealed = SecureCookies.signed(__resolveSessionSecret()).open(req.cookies(), sessionCookieName(__reqIsSecure()));
408
+ if (sealed == null) return;
409
+ SessionRevocations.sessions.claim(new SessionDigest(crypto.toHex(crypto.sha256Text(sealed))), new RevokedSession());
410
+ }
411
+
387
412
  /** A `Set-Cookie` that immediately clears the session (logout). */
388
413
  export function clearSession(): Cookie {
389
414
  const secure = __reqIsSecure();