toiljs 0.0.124 → 0.0.126
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.
- package/CHANGELOG.md +10 -0
- package/README.md +14 -10
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2 -2
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +2 -0
- package/build/client/auth.js +7 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +1 -1
- package/build/compiler/index.js +2 -0
- package/build/compiler/toil-docs.generated.js +13 -13
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/daemon/catalog.d.ts +2 -0
- package/build/devserver/daemon/catalog.js +13 -4
- package/build/devserver/daemon/index.d.ts +4 -0
- package/build/devserver/daemon/index.js +84 -2
- package/build/devserver/db/database.d.ts +11 -2
- package/build/devserver/db/database.js +227 -22
- package/build/devserver/db/limits.d.ts +6 -0
- package/build/devserver/db/limits.js +118 -0
- package/build/devserver/db/materialization.d.ts +4 -0
- package/build/devserver/db/materialization.js +25 -0
- package/build/devserver/db/types.d.ts +3 -0
- package/build/devserver/db/types.js +1 -0
- package/build/devserver/runtime/host.js +17 -0
- package/build/devserver/runtime/module.d.ts +4 -0
- package/build/devserver/runtime/module.js +47 -9
- package/build/devserver/wasm/envelope-window.d.ts +1 -0
- package/build/devserver/wasm/envelope-window.js +64 -0
- package/build/testing/.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/auth/AuthController.ts +265 -61
- package/server/runtime/response.ts +12 -4
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.126",
|
|
5
5
|
"author": "Dacely",
|
|
6
6
|
"description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
|
|
7
7
|
"repository": {
|
|
@@ -154,7 +154,7 @@
|
|
|
154
154
|
"picocolors": "^1.1.1",
|
|
155
155
|
"semver": "^7.8.5",
|
|
156
156
|
"sharp": "^0.35.5",
|
|
157
|
-
"toilscript": ">=0.1.
|
|
157
|
+
"toilscript": ">=0.1.65",
|
|
158
158
|
"vite": "^8.3.4",
|
|
159
159
|
"vite-imagetools": "^12.0.1",
|
|
160
160
|
"vite-plugin-node-polyfills": "^0.28.0"
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
"prettier": ">=3.0.0",
|
|
164
164
|
"react": "^19.3.0",
|
|
165
165
|
"react-dom": "^19.3.0",
|
|
166
|
-
"toilscript": ">=0.1.
|
|
166
|
+
"toilscript": ">=0.1.65",
|
|
167
167
|
"vitest": "^5.0.3",
|
|
168
168
|
"@vitest/browser-webdriverio": "^5.0.0",
|
|
169
169
|
"@vitest/coverage-v8": "^5.0.3",
|
|
@@ -439,8 +439,10 @@ class EmailKey {
|
|
|
439
439
|
@data
|
|
440
440
|
class EmailOwner {
|
|
441
441
|
username: string = '';
|
|
442
|
-
|
|
442
|
+
accountId: Uint8Array = new Uint8Array(0);
|
|
443
|
+
constructor(username: string = '', accountId: Uint8Array = new Uint8Array(0)) {
|
|
443
444
|
this.username = username;
|
|
445
|
+
this.accountId = accountId;
|
|
444
446
|
}
|
|
445
447
|
}
|
|
446
448
|
|
|
@@ -458,6 +460,7 @@ class TokenId {
|
|
|
458
460
|
class TokenRec {
|
|
459
461
|
username: string = '';
|
|
460
462
|
exp: u64 = 0;
|
|
463
|
+
accountId: Uint8Array = new Uint8Array(0);
|
|
461
464
|
}
|
|
462
465
|
|
|
463
466
|
@data
|
|
@@ -492,6 +495,11 @@ class AuthAccount {
|
|
|
492
495
|
// Append-only: the STABLE ToilUserId, derived once at registration. Reset rotates
|
|
493
496
|
// publicKey but leaves this, so the id is stable across a reset.
|
|
494
497
|
toilUserId: Uint8Array = new Uint8Array(0);
|
|
498
|
+
// Account incarnation: never reused, even when username/password are reused.
|
|
499
|
+
accountId: Uint8Array = new Uint8Array(0);
|
|
500
|
+
deleting: bool = false;
|
|
501
|
+
registering: bool = false;
|
|
502
|
+
legacyFactors: bool = false;
|
|
495
503
|
}
|
|
496
504
|
|
|
497
505
|
@data
|
|
@@ -503,6 +511,7 @@ class Challenge {
|
|
|
503
511
|
exp: u64 = 0;
|
|
504
512
|
sessionEpoch: u64 = 0;
|
|
505
513
|
sessionVersion: u8 = 0;
|
|
514
|
+
accountId: Uint8Array = new Uint8Array(0);
|
|
506
515
|
}
|
|
507
516
|
|
|
508
517
|
/** The lookup key for a login 2FA challenge: 16 random bytes minted per login.
|
|
@@ -537,11 +546,30 @@ class TwoFaChallenge {
|
|
|
537
546
|
sessionEpoch: u64 = 0;
|
|
538
547
|
sessionVersion: u8 = 0;
|
|
539
548
|
settingsRevision: u64 = 0;
|
|
549
|
+
accountId: Uint8Array = new Uint8Array(0);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
@data
|
|
553
|
+
class DeletedAccountKey {
|
|
554
|
+
host: string = '';
|
|
555
|
+
incarnation: string = '';
|
|
556
|
+
constructor(host: string = '', incarnation: string = '') {
|
|
557
|
+
this.host = host;
|
|
558
|
+
this.incarnation = incarnation;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
@data
|
|
563
|
+
class DeletedAccountMarker {
|
|
564
|
+
deleted: bool = true;
|
|
540
565
|
}
|
|
541
566
|
|
|
542
567
|
@database
|
|
543
568
|
class AuthDb {
|
|
544
569
|
@collection static accounts: Documents<Username, AuthAccount>;
|
|
570
|
+
// Permanent opaque incarnation guards prevent a late replica/snapshot replay
|
|
571
|
+
// of an old account row from enabling that deleted account again.
|
|
572
|
+
@collection static deletedAccounts: Documents<DeletedAccountKey, DeletedAccountMarker>;
|
|
545
573
|
@collection static emails: Documents<EmailKey, EmailOwner>; // email -> username (uniqueness index)
|
|
546
574
|
@collection static challenges: Documents<ChallengeId, Challenge>;
|
|
547
575
|
@collection static confirmTokens: Documents<TokenId, TokenRec>;
|
|
@@ -623,36 +651,51 @@ class Auth {
|
|
|
623
651
|
// Whether login is then BLOCKED is a separate check (requireConfirmation,
|
|
624
652
|
// used in login/finish), so a domain can verify-at-register without gating.
|
|
625
653
|
const mustConfirm = sendConfirmationEmail();
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
a.parallelism = PAR;
|
|
635
|
-
// Derive + persist the stable id once. Tenant-scoped, and unique WITHOUT a
|
|
636
|
-
// separate index: the framed derive maps distinct (username, domain) to
|
|
637
|
-
// distinct ids, and username is unique per realm (accounts.create below), so
|
|
638
|
-
// two accounts can never share an id.
|
|
639
|
-
a.toilUserId = ToilUserId.derive(pk, username, authDomain(ctx)).toBytes();
|
|
640
|
-
// create-if-absent IS the authoritative, race-safe uniqueness guard
|
|
641
|
-
// (serialized at the key's home): a duplicate username loses here and gets the
|
|
642
|
-
// distinguishable ST_TAKEN. No pre-`exists` read needed -- the create result
|
|
643
|
-
// is the answer.
|
|
644
|
-
if (!AuthDb.accounts.create(new Username(h, username), a)) {
|
|
645
|
-
return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
|
|
654
|
+
const accountKey = new Username(h, username);
|
|
655
|
+
let abandoned = AuthDb.accounts.getCurrentSnapshot(accountKey);
|
|
656
|
+
if (abandoned != null && !abandoned.value.deleting && this.wasDeleted(h, abandoned.value)) {
|
|
657
|
+
// A stale account row may have arrived after its deletion. The
|
|
658
|
+
// permanent incarnation guard authorizes finishing its cleanup again.
|
|
659
|
+
abandoned.value.deleting = true;
|
|
660
|
+
if (!AuthDb.accounts.compareExchangeSnapshot(accountKey, abandoned, abandoned.value)) return fail();
|
|
661
|
+
abandoned = new DocumentSnapshot<AuthAccount>(abandoned.value, abandoned.value.encode());
|
|
646
662
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
663
|
+
if (abandoned != null && abandoned.value.deleting) this.finishDeletion(ctx, accountKey, abandoned);
|
|
664
|
+
let a: AuthAccount;
|
|
665
|
+
let registering: DocumentSnapshot<AuthAccount>;
|
|
666
|
+
if (abandoned != null && abandoned.value.registering && abandoned.value.email == email &&
|
|
667
|
+
ctEqual(abandoned.value.publicKey, pk)) {
|
|
668
|
+
// The verified password proof authorizes resuming this exact pending
|
|
669
|
+
// registration after a crash between account and email reservation.
|
|
670
|
+
a = abandoned.value;
|
|
671
|
+
registering = abandoned;
|
|
672
|
+
} else {
|
|
673
|
+
a = new AuthAccount();
|
|
674
|
+
a.accountId = randomBytes(32);
|
|
675
|
+
a.registering = true;
|
|
676
|
+
a.username = username;
|
|
677
|
+
a.email = email;
|
|
678
|
+
a.emailConfirmed = !mustConfirm;
|
|
679
|
+
a.salt = deriveSalt(h, username);
|
|
680
|
+
a.publicKey = pk;
|
|
681
|
+
a.memKiB = MEM_KIB;
|
|
682
|
+
a.iterations = ITERS;
|
|
683
|
+
a.parallelism = PAR;
|
|
684
|
+
a.toilUserId = ToilUserId.derive(pk, username, authDomain(ctx)).toBytes();
|
|
685
|
+
if (!AuthDb.accounts.compareExchange(accountKey, null, a)) {
|
|
686
|
+
return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
|
|
687
|
+
}
|
|
688
|
+
registering = new DocumentSnapshot<AuthAccount>(a, a.encode());
|
|
689
|
+
}
|
|
690
|
+
if (!this.reserveEmail(new EmailKey(h, email), a)) {
|
|
691
|
+
AuthDb.accounts.compareDeleteSnapshot(accountKey, registering);
|
|
651
692
|
return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
|
|
652
693
|
}
|
|
653
694
|
|
|
695
|
+
a.registering = false;
|
|
696
|
+
if (!AuthDb.accounts.compareExchangeSnapshot(accountKey, registering, a)) return fail();
|
|
654
697
|
if (mustConfirm) {
|
|
655
|
-
this.issueConfirmation(h, ctx, username, email);
|
|
698
|
+
this.issueConfirmation(h, ctx, username, email, a);
|
|
656
699
|
}
|
|
657
700
|
return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
658
701
|
}
|
|
@@ -673,11 +716,14 @@ class Auth {
|
|
|
673
716
|
const rec = AuthDb.confirmTokens.getDelete(tokenId(h, raw));
|
|
674
717
|
if (rec == null) return fail();
|
|
675
718
|
if (nowSecs() >= rec.exp) return fail();
|
|
676
|
-
const
|
|
677
|
-
|
|
719
|
+
const key = new Username(h, rec.username);
|
|
720
|
+
const observed = AuthDb.accounts.getCurrentSnapshot(key);
|
|
721
|
+
if (observed == null) return fail();
|
|
722
|
+
const acct = observed.value;
|
|
723
|
+
if (acct.deleting || acct.registering || this.wasDeleted(h, acct) || !ctEqual(rec.accountId, this.incarnation(acct))) return fail();
|
|
678
724
|
if (!acct.emailConfirmed) {
|
|
679
725
|
acct.emailConfirmed = true;
|
|
680
|
-
AuthDb.accounts.
|
|
726
|
+
if (!AuthDb.accounts.compareExchangeSnapshot(key, observed, acct)) return fail();
|
|
681
727
|
}
|
|
682
728
|
return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
683
729
|
}
|
|
@@ -694,9 +740,9 @@ class Auth {
|
|
|
694
740
|
const h = realm(ctx);
|
|
695
741
|
const owner = AuthDb.emails.get(new EmailKey(h, email));
|
|
696
742
|
if (owner != null) {
|
|
697
|
-
const acct =
|
|
698
|
-
if (acct != null && !acct.emailConfirmed) {
|
|
699
|
-
this.issueConfirmation(h, ctx, owner.username, email);
|
|
743
|
+
const acct = this.account(new Username(h, owner.username));
|
|
744
|
+
if (acct != null && acct.email == email && !acct.emailConfirmed) {
|
|
745
|
+
this.issueConfirmation(h, ctx, owner.username, email, acct);
|
|
700
746
|
}
|
|
701
747
|
}
|
|
702
748
|
return ackOk();
|
|
@@ -719,7 +765,7 @@ class Auth {
|
|
|
719
765
|
if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
|
|
720
766
|
|
|
721
767
|
const h = realm(ctx);
|
|
722
|
-
const known =
|
|
768
|
+
const known = this.account(new Username(h, username));
|
|
723
769
|
const cid = randomBytes(16);
|
|
724
770
|
const nonce = randomBytes(32);
|
|
725
771
|
const iat = nowSecs();
|
|
@@ -728,16 +774,17 @@ class Auth {
|
|
|
728
774
|
// Persist only for a real account; the response is identical either way,
|
|
729
775
|
// and login/finish for an unknown user fails generically at consume. The
|
|
730
776
|
// challenge key folds in `h`, so a challenge is bound to the login domain.
|
|
731
|
-
if (known) {
|
|
777
|
+
if (known != null) {
|
|
732
778
|
const c = new Challenge();
|
|
733
779
|
c.cid = cid;
|
|
734
780
|
c.username = username;
|
|
735
781
|
c.nonce = nonce;
|
|
736
782
|
c.iat = iat;
|
|
737
783
|
c.exp = exp;
|
|
738
|
-
const account =
|
|
784
|
+
const account = known;
|
|
739
785
|
c.sessionEpoch = SessionEpochs.current(__toilEncodeAuthUser(this.stableUserId(ctx, h, username, account), username));
|
|
740
786
|
c.sessionVersion = 2;
|
|
787
|
+
c.accountId = this.incarnation(account);
|
|
741
788
|
AuthDb.challenges.create(new ChallengeId(h, cid), c);
|
|
742
789
|
}
|
|
743
790
|
|
|
@@ -777,8 +824,8 @@ class Auth {
|
|
|
777
824
|
|
|
778
825
|
// 2. Rebuild the message from OUR stored values + the client's ct (and
|
|
779
826
|
// the bound params + server key id), load the account key, verify.
|
|
780
|
-
const acct =
|
|
781
|
-
if (acct == null) return fail();
|
|
827
|
+
const acct = this.account(new Username(h, ch.username));
|
|
828
|
+
if (acct == null || !ctEqual(ch.accountId, this.incarnation(acct))) return fail();
|
|
782
829
|
const message = AuthService.buildLoginMessage(
|
|
783
830
|
ch.username,
|
|
784
831
|
aud(),
|
|
@@ -820,7 +867,7 @@ class Auth {
|
|
|
820
867
|
// never resetTokens/confirmTokens). This runs AFTER the signature
|
|
821
868
|
// verify above, so it is not an oracle, and the login `Challenge` was
|
|
822
869
|
// already consumed (getDelete) at the top of this handler.
|
|
823
|
-
const factorMethod = FactorRepository.read(h,
|
|
870
|
+
const factorMethod = FactorRepository.read(h, this.factorName(acct), acct.twoFactorMethod).method;
|
|
824
871
|
if (factorMethod != TWOFA_NONE) {
|
|
825
872
|
const raw = randomBytes(16); // the twoFaId the client echoes to /2fa/verify
|
|
826
873
|
const codeHash = this.twoFaDeliver(
|
|
@@ -834,6 +881,7 @@ class Auth {
|
|
|
834
881
|
if (codeHash.length == 0) return fail(); // unsupported/misconfigured method
|
|
835
882
|
const c2 = new TwoFaChallenge();
|
|
836
883
|
c2.username = ch.username;
|
|
884
|
+
c2.accountId = ch.accountId;
|
|
837
885
|
c2.method = factorMethod;
|
|
838
886
|
c2.codeHash = codeHash;
|
|
839
887
|
c2.exp = nowSecs() + TWOFA_TTL_SECS;
|
|
@@ -879,19 +927,21 @@ class Auth {
|
|
|
879
927
|
if (!r.ok) return fail();
|
|
880
928
|
const h = realm(ctx);
|
|
881
929
|
const owner = AuthDb.emails.get(new EmailKey(h, email));
|
|
882
|
-
|
|
930
|
+
const resetAccount: AuthAccount | null = owner == null ? null : this.account(new Username(h, owner.username));
|
|
931
|
+
if (owner != null && resetAccount != null && resetAccount.email == email) {
|
|
883
932
|
// Invalidate this user's previous reset token (bound to one outstanding).
|
|
884
|
-
const prev = AuthDb.resetTokenOf.getDelete(
|
|
933
|
+
const prev = AuthDb.resetTokenOf.getDelete(this.secondaryKey(h, resetAccount));
|
|
885
934
|
if (prev != null) AuthDb.resetTokens.delete(prev);
|
|
886
935
|
const raw = mintToken();
|
|
887
936
|
const tid = tokenId(h, raw); // realm-bound: unredeemable on another domain
|
|
888
937
|
const rec = new TokenRec();
|
|
889
938
|
rec.username = owner.username;
|
|
939
|
+
rec.accountId = this.incarnation(resetAccount);
|
|
890
940
|
rec.exp = nowSecs() + RESET_TTL_SECS;
|
|
891
941
|
AuthDb.resetTokens.create(tid, rec);
|
|
892
942
|
// If a concurrent reset request won the pointer index, roll back the
|
|
893
943
|
// token we just minted so exactly one stays outstanding (no orphan).
|
|
894
|
-
if (!AuthDb.resetTokenOf.create(
|
|
944
|
+
if (!AuthDb.resetTokenOf.create(this.secondaryKey(h, resetAccount), tid)) {
|
|
895
945
|
AuthDb.resetTokens.delete(tid);
|
|
896
946
|
}
|
|
897
947
|
// Token in the URL FRAGMENT, not the query string: a fragment is never
|
|
@@ -955,8 +1005,11 @@ class Auth {
|
|
|
955
1005
|
const rec = AuthDb.resetTokens.getDelete(tokenId(h, raw));
|
|
956
1006
|
if (rec == null) return fail();
|
|
957
1007
|
if (nowSecs() >= rec.exp) return fail();
|
|
958
|
-
const
|
|
959
|
-
|
|
1008
|
+
const key = new Username(h, rec.username);
|
|
1009
|
+
const observed = AuthDb.accounts.getCurrentSnapshot(key);
|
|
1010
|
+
if (observed == null) return fail();
|
|
1011
|
+
const acct = observed.value;
|
|
1012
|
+
if (acct.deleting || acct.registering || this.wasDeleted(h, acct) || !ctEqual(rec.accountId, this.incarnation(acct))) return fail();
|
|
960
1013
|
|
|
961
1014
|
const regMsg = AuthService.buildRegisterMessage(rec.username, pk);
|
|
962
1015
|
if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
|
|
@@ -964,24 +1017,169 @@ class Auth {
|
|
|
964
1017
|
// Overwrite ONLY the login verifier. Salt/params are deterministic
|
|
965
1018
|
// constants (unchanged); a successful reset also implies email ownership,
|
|
966
1019
|
// so confirm the account while we are here.
|
|
967
|
-
const stableId = this.stableUserId(ctx, h, rec.username, acct);
|
|
1020
|
+
const stableId = this.stableUserId(ctx, h, rec.username, acct, false);
|
|
1021
|
+
acct.toilUserId = stableId;
|
|
1022
|
+
if (acct.accountId.length == 0) acct.legacyFactors = true;
|
|
1023
|
+
acct.accountId = this.incarnation(acct);
|
|
968
1024
|
acct.publicKey = pk;
|
|
969
1025
|
acct.emailConfirmed = true;
|
|
970
|
-
AuthDb.accounts.
|
|
1026
|
+
if (!AuthDb.accounts.compareExchangeSnapshot(key, observed, acct)) return fail();
|
|
971
1027
|
AuthService.revokeUserSessions(__toilEncodeAuthUser(stableId, rec.username));
|
|
972
1028
|
return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
973
1029
|
}
|
|
974
1030
|
|
|
975
1031
|
/** The account's STABLE ToilUserId, persisted at registration (so reset never
|
|
976
1032
|
* changes it). An empty stored id is derived once, persisted, and indexed. */
|
|
977
|
-
private stableUserId(ctx: RouteContext, h: string, username: string, acct: AuthAccount): Uint8Array {
|
|
1033
|
+
private stableUserId(ctx: RouteContext, h: string, username: string, acct: AuthAccount, persist: bool = true): Uint8Array {
|
|
978
1034
|
if (acct.toilUserId.length > 0) return acct.toilUserId;
|
|
979
1035
|
const uid = ToilUserId.derive(acct.publicKey, username, authDomain(ctx)).toBytes();
|
|
1036
|
+
if (!persist || acct.deleting) return uid;
|
|
1037
|
+
const key = new Username(h, username);
|
|
1038
|
+
const observed = AuthDb.accounts.getCurrentSnapshot(key);
|
|
1039
|
+
if (observed == null || observed.value.deleting || observed.value.registering ||
|
|
1040
|
+
!ctEqual(this.incarnation(observed.value), this.incarnation(acct)) || this.wasDeleted(h, observed.value))
|
|
1041
|
+
throw new Error('Account identity temporarily unavailable');
|
|
1042
|
+
if (observed.value.toilUserId.length != 0) {
|
|
1043
|
+
acct.toilUserId = observed.value.toilUserId;
|
|
1044
|
+
return acct.toilUserId;
|
|
1045
|
+
}
|
|
1046
|
+
observed.value.toilUserId = uid;
|
|
1047
|
+
if (!AuthDb.accounts.compareExchangeSnapshot(key, observed, observed.value))
|
|
1048
|
+
throw new Error('Account identity changed; retry sign-in');
|
|
980
1049
|
acct.toilUserId = uid;
|
|
981
|
-
AuthDb.accounts.patch(new Username(h, username), acct);
|
|
982
1050
|
return uid;
|
|
983
1051
|
}
|
|
984
1052
|
|
|
1053
|
+
private reserveEmail(key: EmailKey, acct: AuthAccount): bool {
|
|
1054
|
+
const desired = new EmailOwner(acct.username, acct.accountId);
|
|
1055
|
+
if (AuthDb.emails.compareExchange(key, null, desired)) return true;
|
|
1056
|
+
const reserved = AuthDb.emails.getCurrentSnapshot(key);
|
|
1057
|
+
if (reserved == null) {
|
|
1058
|
+
if (Db.lastError() != DbError.None) throw new Error('Email index temporarily unavailable');
|
|
1059
|
+
return AuthDb.emails.compareExchange(key, null, desired);
|
|
1060
|
+
}
|
|
1061
|
+
if (reserved.value.username == acct.username && ctEqual(reserved.value.accountId, acct.accountId)) return true;
|
|
1062
|
+
// A deleted owner's index may arrive late from a replica, or survive an
|
|
1063
|
+
// interrupted registration. Reclaim only if its authoritative account
|
|
1064
|
+
// no longer owns this email. Preserve reservations for pending accounts.
|
|
1065
|
+
const prior = AuthDb.accounts.getCurrentSnapshot(new Username(key.host, reserved.value.username));
|
|
1066
|
+
if (prior == null && Db.lastError() != DbError.None) throw new Error('Email owner temporarily unavailable');
|
|
1067
|
+
if (prior != null && !prior.value.deleting && prior.value.email == key.email && !this.wasDeleted(key.host, prior.value)) {
|
|
1068
|
+
const ownerMatches = reserved.value.accountId.length == 0
|
|
1069
|
+
? prior.value.accountId.length == 0 || prior.value.legacyFactors
|
|
1070
|
+
: ctEqual(reserved.value.accountId, this.incarnation(prior.value));
|
|
1071
|
+
if (ownerMatches) return false;
|
|
1072
|
+
}
|
|
1073
|
+
if (!AuthDb.emails.compareDeleteSnapshot(key, reserved)) return false;
|
|
1074
|
+
return AuthDb.emails.compareExchange(key, null, desired);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
private account(key: Username): AuthAccount | null {
|
|
1078
|
+
const observed = AuthDb.accounts.getCurrentSnapshot(key);
|
|
1079
|
+
if (observed == null) {
|
|
1080
|
+
if (Db.lastError() != DbError.None) throw new Error('Account temporarily unavailable');
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
if (observed.value.deleting || observed.value.registering || this.wasDeleted(key.host, observed.value)) return null;
|
|
1084
|
+
return observed.value;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
private wasDeleted(host: string, acct: AuthAccount): bool {
|
|
1088
|
+
const marker = AuthDb.deletedAccounts.getCurrentSnapshot(new DeletedAccountKey(host, crypto.toHex(this.incarnation(acct))));
|
|
1089
|
+
if (marker == null && Db.lastError() != DbError.None) throw new Error('Account deletion state temporarily unavailable');
|
|
1090
|
+
if (marker != null) return true;
|
|
1091
|
+
if (acct.accountId.length == 0 || acct.legacyFactors) {
|
|
1092
|
+
// Older credentials predate an incarnation field. Their salt remains
|
|
1093
|
+
// stable across resets, unlike the public key used in a legacy fallback.
|
|
1094
|
+
const legacy = AuthDb.deletedAccounts.getCurrentSnapshot(new DeletedAccountKey(host, 'legacy:' + crypto.toHex(crypto.sha256(acct.salt))));
|
|
1095
|
+
if (legacy == null && Db.lastError() != DbError.None) throw new Error('Account deletion state temporarily unavailable');
|
|
1096
|
+
return legacy != null;
|
|
1097
|
+
}
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
private incarnation(acct: AuthAccount): Uint8Array {
|
|
1102
|
+
if (acct.accountId.length != 0) return acct.accountId;
|
|
1103
|
+
// Legacy accounts have no incarnation. Preserve this identity across reset.
|
|
1104
|
+
return crypto.sha256Text(crypto.toHex(acct.salt) + ':' + crypto.toHex(acct.publicKey));
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
private factorName(acct: AuthAccount): string {
|
|
1108
|
+
return acct.accountId.length == 0 || acct.legacyFactors ? acct.username : acct.username + '\x00' + crypto.toHex(acct.accountId);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
private secondaryKey(host: string, acct: AuthAccount): Username {
|
|
1112
|
+
return new Username(host, acct.username + '\x00' + crypto.toHex(this.incarnation(acct)));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private finishDeletion(ctx: RouteContext, key: Username, observed: DocumentSnapshot<AuthAccount>): bool {
|
|
1116
|
+
const acct = observed.value;
|
|
1117
|
+
if (!acct.deleting) return false;
|
|
1118
|
+
const deletedKey = new DeletedAccountKey(key.host, crypto.toHex(this.incarnation(acct)));
|
|
1119
|
+
if (!AuthDb.deletedAccounts.compareExchange(deletedKey, null, new DeletedAccountMarker()) && !this.wasDeleted(key.host, acct))
|
|
1120
|
+
throw new Error('Account deletion state temporarily unavailable');
|
|
1121
|
+
if (acct.accountId.length == 0 || acct.legacyFactors) {
|
|
1122
|
+
const legacyKey = new DeletedAccountKey(key.host, 'legacy:' + crypto.toHex(crypto.sha256(acct.salt)));
|
|
1123
|
+
if (!AuthDb.deletedAccounts.compareExchange(legacyKey, null, new DeletedAccountMarker())) {
|
|
1124
|
+
const legacy = AuthDb.deletedAccounts.getCurrentSnapshot(legacyKey);
|
|
1125
|
+
if (legacy == null) throw new Error('Legacy deletion state temporarily unavailable');
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
const emailKey = new EmailKey(key.host, acct.email);
|
|
1129
|
+
const email = AuthDb.emails.getCurrentSnapshot(emailKey);
|
|
1130
|
+
if (email == null && Db.lastError() != DbError.None) throw new Error('Email index temporarily unavailable');
|
|
1131
|
+
if (email != null && email.value.username == acct.username &&
|
|
1132
|
+
(email.value.accountId.length == 0 || ctEqual(email.value.accountId, this.incarnation(acct)))) {
|
|
1133
|
+
AuthDb.emails.compareDeleteSnapshot(emailKey, email);
|
|
1134
|
+
}
|
|
1135
|
+
const secondary = this.secondaryKey(key.host, acct);
|
|
1136
|
+
const confirm = AuthDb.confirmTokenOf.getDelete(secondary);
|
|
1137
|
+
if (confirm == null && Db.lastError() != DbError.None) throw new Error('Confirmation token temporarily unavailable');
|
|
1138
|
+
if (confirm != null) AuthDb.confirmTokens.delete(confirm);
|
|
1139
|
+
const reset = AuthDb.resetTokenOf.getDelete(secondary);
|
|
1140
|
+
if (reset == null && Db.lastError() != DbError.None) throw new Error('Reset token temporarily unavailable');
|
|
1141
|
+
if (reset != null) AuthDb.resetTokens.delete(reset);
|
|
1142
|
+
AuthDb.twoFaSetup.delete(secondary);
|
|
1143
|
+
// Pre-incarnation heads use the plain username. New accounts never use
|
|
1144
|
+
// these keys, so cleaning them cannot touch a later registration.
|
|
1145
|
+
const legacy = new Username(key.host, acct.username);
|
|
1146
|
+
const legacyConfirm = AuthDb.confirmTokenOf.getDelete(legacy);
|
|
1147
|
+
if (legacyConfirm == null && Db.lastError() != DbError.None) throw new Error('Confirmation token temporarily unavailable');
|
|
1148
|
+
if (legacyConfirm != null) AuthDb.confirmTokens.delete(legacyConfirm);
|
|
1149
|
+
const legacyReset = AuthDb.resetTokenOf.getDelete(legacy);
|
|
1150
|
+
if (legacyReset == null && Db.lastError() != DbError.None) throw new Error('Reset token temporarily unavailable');
|
|
1151
|
+
if (legacyReset != null) AuthDb.resetTokens.delete(legacyReset);
|
|
1152
|
+
AuthDb.twoFaSetup.delete(legacy);
|
|
1153
|
+
// Security epochs intentionally remain so copied cookies and in-flight
|
|
1154
|
+
// logins cannot become valid when the same identity signs up again.
|
|
1155
|
+
AuthService.revokeUserSessions(__toilEncodeAuthUser(this.stableUserId(ctx, key.host, acct.username, acct), acct.username));
|
|
1156
|
+
return AuthDb.accounts.compareDeleteSnapshot(key, observed);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Delete the authenticated sign-in account, freeing username and email.
|
|
1160
|
+
* App-owned data should be cleaned by the application before this request. */
|
|
1161
|
+
@auth
|
|
1162
|
+
@post('/delete')
|
|
1163
|
+
public deleteAccount(ctx: RouteContext): Response {
|
|
1164
|
+
const user = AuthService.getUser();
|
|
1165
|
+
if (user == null) return fail();
|
|
1166
|
+
const h = realm(ctx);
|
|
1167
|
+
const key = new Username(h, user.username);
|
|
1168
|
+
let observed = AuthDb.accounts.getCurrentSnapshot(key);
|
|
1169
|
+
if (observed == null || !ctEqual(this.stableUserId(ctx, h, user.username, observed.value, false), user.toilUserId)) return fail();
|
|
1170
|
+
if (!observed.value.deleting) {
|
|
1171
|
+
const acct = observed.value;
|
|
1172
|
+
acct.deleting = true;
|
|
1173
|
+
if (!AuthDb.accounts.compareExchangeSnapshot(key, observed, acct)) return fail();
|
|
1174
|
+
observed = new DocumentSnapshot<AuthAccount>(acct, acct.encode());
|
|
1175
|
+
}
|
|
1176
|
+
if (!this.finishDeletion(ctx, key, observed)) return fail();
|
|
1177
|
+
const response = Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
1178
|
+
response.setCookie(AuthService.clearSession());
|
|
1179
|
+
response.setCookie(AuthService.clearUserCookie());
|
|
1180
|
+
return response;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
985
1183
|
/** GET /auth/me (@auth: 401 without a valid session) -> the typed user
|
|
986
1184
|
* (bytes(toilUserId) str(username)). `AuthService.getUser()` is auto-typed
|
|
987
1185
|
* to the built-in `@user` with no type argument. */
|
|
@@ -1073,8 +1271,8 @@ class Auth {
|
|
|
1073
1271
|
// SUCCESS: consume-once, then mint the session EXACTLY like login/finish.
|
|
1074
1272
|
const consumed = AuthDb.twoFaLogins.getDelete(key);
|
|
1075
1273
|
if (consumed == null) return fail();
|
|
1076
|
-
const acct =
|
|
1077
|
-
if (acct == null) return fail();
|
|
1274
|
+
const acct = this.account(new Username(h, ch.username));
|
|
1275
|
+
if (acct == null || !ctEqual(consumed.accountId, this.incarnation(acct))) return fail();
|
|
1078
1276
|
const toilUserId = this.stableUserId(ctx, h, ch.username, acct);
|
|
1079
1277
|
const userData = __toilEncodeAuthUser(toilUserId, ch.username);
|
|
1080
1278
|
const w = new DataWriter();
|
|
@@ -1104,10 +1302,10 @@ class Auth {
|
|
|
1104
1302
|
const u = AuthService.getUser();
|
|
1105
1303
|
if (u == null) return fail();
|
|
1106
1304
|
const h = realm(_ctx);
|
|
1107
|
-
const acct =
|
|
1305
|
+
const acct = this.account(new Username(h, u.username));
|
|
1108
1306
|
if (acct == null) return fail();
|
|
1109
1307
|
|
|
1110
|
-
const settings = FactorRepository.read(h,
|
|
1308
|
+
const settings = FactorRepository.read(h, this.factorName(acct), acct.twoFactorMethod);
|
|
1111
1309
|
// Disabling when 2FA is already off: nothing to prove; generic ST_OK.
|
|
1112
1310
|
if (targetMethod == TWOFA_NONE && settings.method == TWOFA_NONE) {
|
|
1113
1311
|
return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
@@ -1136,6 +1334,7 @@ class Auth {
|
|
|
1136
1334
|
|
|
1137
1335
|
const c = new TwoFaChallenge();
|
|
1138
1336
|
c.username = u.username;
|
|
1337
|
+
c.accountId = this.incarnation(acct);
|
|
1139
1338
|
c.settingsRevision = settings.revision;
|
|
1140
1339
|
c.sessionVersion = 2;
|
|
1141
1340
|
c.sessionEpoch = SessionEpochs.current(AuthService.getSessionBytes()!);
|
|
@@ -1145,8 +1344,8 @@ class Auth {
|
|
|
1145
1344
|
c.attempts = 0;
|
|
1146
1345
|
c.targetMethod = targetMethod; // what to set account.twoFactorMethod to on success
|
|
1147
1346
|
// One outstanding setup challenge per user: overwrite any prior.
|
|
1148
|
-
AuthDb.twoFaSetup.delete(
|
|
1149
|
-
AuthDb.twoFaSetup.create(
|
|
1347
|
+
AuthDb.twoFaSetup.delete(this.secondaryKey(h, acct));
|
|
1348
|
+
AuthDb.twoFaSetup.create(this.secondaryKey(h, acct), c);
|
|
1150
1349
|
return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
1151
1350
|
}
|
|
1152
1351
|
|
|
@@ -1179,7 +1378,10 @@ class Auth {
|
|
|
1179
1378
|
);
|
|
1180
1379
|
if (srl != null) return srl;
|
|
1181
1380
|
|
|
1182
|
-
const
|
|
1381
|
+
const accountKey = new Username(h, u.username);
|
|
1382
|
+
const current = this.account(accountKey);
|
|
1383
|
+
if (current == null) return fail();
|
|
1384
|
+
const key = this.secondaryKey(h, current);
|
|
1183
1385
|
const ch = AuthDb.twoFaSetup.get(key);
|
|
1184
1386
|
if (ch == null || ch.sessionVersion != 2 || !SessionEpochs.allowed(AuthService.getSessionBytes()!, ch.sessionEpoch)) return fail();
|
|
1185
1387
|
if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
|
|
@@ -1196,9 +1398,9 @@ class Auth {
|
|
|
1196
1398
|
// SUCCESS: consume-once + apply the method change.
|
|
1197
1399
|
const consumed = AuthDb.twoFaSetup.getDelete(key);
|
|
1198
1400
|
if (consumed == null || nowSecs() >= consumed.exp || !this.twoFaVerifyCode(h, consumed.username, code, consumed)) return fail();
|
|
1199
|
-
const acct =
|
|
1200
|
-
if (acct == null) return fail();
|
|
1201
|
-
if (!FactorRepository.save(h,
|
|
1401
|
+
const acct = this.account(accountKey);
|
|
1402
|
+
if (acct == null || !ctEqual(consumed.accountId, this.incarnation(acct))) return fail();
|
|
1403
|
+
if (!FactorRepository.save(h, this.factorName(acct), consumed.targetMethod, consumed.settingsRevision)) return fail();
|
|
1202
1404
|
const userData = __toilEncodeAuthUser(this.stableUserId(_ctx, h, u.username, acct), u.username);
|
|
1203
1405
|
AuthService.revokeUserSessions(userData);
|
|
1204
1406
|
const response = Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
@@ -1215,9 +1417,9 @@ class Auth {
|
|
|
1215
1417
|
const u = AuthService.getUser();
|
|
1216
1418
|
if (u == null) return fail();
|
|
1217
1419
|
const h = realm(_ctx);
|
|
1218
|
-
const acct =
|
|
1420
|
+
const acct = this.account(new Username(h, u.username));
|
|
1219
1421
|
if (acct == null) return fail();
|
|
1220
|
-
return Response.bytes(new DataWriter().writeU8(FactorRepository.read(h,
|
|
1422
|
+
return Response.bytes(new DataWriter().writeU8(FactorRepository.read(h, this.factorName(acct), acct.twoFactorMethod).method).toBytes());
|
|
1221
1423
|
}
|
|
1222
1424
|
|
|
1223
1425
|
/**
|
|
@@ -1296,19 +1498,21 @@ class Auth {
|
|
|
1296
1498
|
ctx: RouteContext,
|
|
1297
1499
|
username: string,
|
|
1298
1500
|
email: string,
|
|
1501
|
+
account: AuthAccount,
|
|
1299
1502
|
): void {
|
|
1300
1503
|
// Invalidate this user's previous confirm token (bound to one outstanding).
|
|
1301
|
-
const prev = AuthDb.confirmTokenOf.getDelete(
|
|
1504
|
+
const prev = AuthDb.confirmTokenOf.getDelete(this.secondaryKey(host, account));
|
|
1302
1505
|
if (prev != null) AuthDb.confirmTokens.delete(prev);
|
|
1303
1506
|
const raw = mintToken();
|
|
1304
1507
|
const tid = tokenId(host, raw); // realm-bound token id (see tokenId)
|
|
1305
1508
|
const rec = new TokenRec();
|
|
1306
1509
|
rec.username = username;
|
|
1510
|
+
rec.accountId = this.incarnation(account);
|
|
1307
1511
|
rec.exp = nowSecs() + CONFIRM_TTL_SECS;
|
|
1308
1512
|
AuthDb.confirmTokens.create(tid, rec);
|
|
1309
1513
|
// If a concurrent request won the pointer index, roll back the token we
|
|
1310
1514
|
// just minted so exactly one stays outstanding (no orphan).
|
|
1311
|
-
if (!AuthDb.confirmTokenOf.create(
|
|
1515
|
+
if (!AuthDb.confirmTokenOf.create(this.secondaryKey(host, account), tid)) {
|
|
1312
1516
|
AuthDb.confirmTokens.delete(tid);
|
|
1313
1517
|
}
|
|
1314
1518
|
// Token in the URL FRAGMENT (see resetRequest): kept out of server/CDN
|
|
@@ -59,13 +59,17 @@ export class Response {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* A raw binary body,
|
|
62
|
+
* A raw binary body, defaulting to `application/octet-stream`. Used by `@route`
|
|
63
63
|
* methods with `stream: DataStream.Binary` to ship a `@data` `encode()`.
|
|
64
64
|
*/
|
|
65
|
-
public static bytes(
|
|
65
|
+
public static bytes(
|
|
66
|
+
body: Uint8Array,
|
|
67
|
+
status: u16 = 200,
|
|
68
|
+
contentType: string = 'application/octet-stream',
|
|
69
|
+
): Response {
|
|
66
70
|
const r = new Response(status, body);
|
|
67
71
|
|
|
68
|
-
r.setHeader('content-type',
|
|
72
|
+
r.setHeader('content-type', contentType);
|
|
69
73
|
|
|
70
74
|
return r;
|
|
71
75
|
}
|
|
@@ -100,9 +104,13 @@ export class Response {
|
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
/**
|
|
103
|
-
*
|
|
107
|
+
* Replace all headers with this name (case-insensitive). Returns `this`.
|
|
104
108
|
*/
|
|
105
109
|
public setHeader(name: string, value: string): Response {
|
|
110
|
+
const lower = name.toLowerCase();
|
|
111
|
+
for (let i = this.headers.length - 1; i >= 0; i--) {
|
|
112
|
+
if (this.headers[i].name.toLowerCase() === lower) this.headers.splice(i, 1);
|
|
113
|
+
}
|
|
106
114
|
this.headers.push(new Header(name, value));
|
|
107
115
|
|
|
108
116
|
return this;
|