@nacre.work/core 0.17.4 → 0.18.0
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/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +114 -0
- package/dist/config.js.map +1 -1
- package/dist/cors.d.ts +7 -0
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +33 -0
- package/dist/cors.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/mail.d.ts +43 -0
- package/dist/mail.d.ts.map +1 -0
- package/dist/mail.js +92 -0
- package/dist/mail.js.map +1 -0
- package/dist/migrations/0029_second_factor.sql +111 -0
- package/dist/migrations/0030_password_reset.sql +59 -0
- package/dist/passwords.d.ts.map +1 -1
- package/dist/passwords.js +8 -0
- package/dist/passwords.js.map +1 -1
- package/dist/totp.d.ts +79 -0
- package/dist/totp.d.ts.map +1 -0
- package/dist/totp.js +206 -0
- package/dist/totp.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
-- 0029 — a second factor, and the codes that get you back in without one.
|
|
2
|
+
--
|
|
3
|
+
-- Everything that authenticates a person here has been one secret: a password,
|
|
4
|
+
-- or an ID token from an issuer the operator trusts. A password that leaks is
|
|
5
|
+
-- an account, and on this product an account is a set of documents somebody
|
|
6
|
+
-- decided who may read.
|
|
7
|
+
--
|
|
8
|
+
-- The specification is docs/authz.md, "Authentication", and the rule it does
|
|
9
|
+
-- **not** change: a second factor decides whether a session starts. It grants
|
|
10
|
+
-- nothing. Every permission is still computed per request from `grants`.
|
|
11
|
+
|
|
12
|
+
-- ─────────── the factors ───────────
|
|
13
|
+
--
|
|
14
|
+
-- One row per enrolled authenticator, so a person can carry two and lose one.
|
|
15
|
+
-- `kind` admits only 'totp' today; WebAuthn widens this CHECK in its own
|
|
16
|
+
-- migration rather than being written here on speculation — a column shaped for
|
|
17
|
+
-- a feature nobody has built is a column the next person has to guess about.
|
|
18
|
+
CREATE TABLE user_second_factors (
|
|
19
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
20
|
+
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
|
21
|
+
user_id uuid NOT NULL,
|
|
22
|
+
kind text NOT NULL CHECK (kind IN ('totp')),
|
|
23
|
+
|
|
24
|
+
-- The shared secret, sealed. See `packages/core/authn/totp.ts`: AES-256-GCM
|
|
25
|
+
-- under a key from `NACRE_2FA_KEY_REF`, with the nonce and the tag inside the
|
|
26
|
+
-- value the way `password_hash` carries its scrypt parameters. A database
|
|
27
|
+
-- dump is a thing that happens; a dump that hands over every second factor in
|
|
28
|
+
-- plaintext is a second factor that was never one.
|
|
29
|
+
secret text NOT NULL,
|
|
30
|
+
|
|
31
|
+
-- What a person calls it, because "which of my two phones is this" is a
|
|
32
|
+
-- question they will have and the database cannot answer.
|
|
33
|
+
label text NOT NULL,
|
|
34
|
+
|
|
35
|
+
-- Enrolment is two steps and this is the second: a secret that has never
|
|
36
|
+
-- produced a correct code is a secret the person has not actually got into
|
|
37
|
+
-- their authenticator, and treating it as live is how somebody locks
|
|
38
|
+
-- themselves out at the moment they turn 2FA on. Nothing counts an
|
|
39
|
+
-- unconfirmed row.
|
|
40
|
+
confirmed_at timestamptz,
|
|
41
|
+
|
|
42
|
+
-- The time step of the last accepted code. TOTP without this accepts the same
|
|
43
|
+
-- six digits for the whole window, so an attacker who sees one over a
|
|
44
|
+
-- shoulder — or in a phishing proxy — can replay it. Monotonic per factor.
|
|
45
|
+
last_step bigint,
|
|
46
|
+
|
|
47
|
+
-- Brute force is bounded here rather than in Redis. The rate limiter fails
|
|
48
|
+
-- **open** by design, on the argument that it is not an authorization control
|
|
49
|
+
-- and a cache restart must not be an outage. This one is an authorization
|
|
50
|
+
-- control: six digits is a million, and a limiter that forgets is a limiter
|
|
51
|
+
-- an attacker waits out.
|
|
52
|
+
failed_attempts integer NOT NULL DEFAULT 0,
|
|
53
|
+
locked_until timestamptz,
|
|
54
|
+
|
|
55
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
56
|
+
last_used_at timestamptz,
|
|
57
|
+
|
|
58
|
+
-- Composite, so a row cannot join a factor in one organization to a user in
|
|
59
|
+
-- another. `group_members` had the plain version of this shape and it would
|
|
60
|
+
-- have handed a foreign user every grant a group held.
|
|
61
|
+
FOREIGN KEY (user_id, org_id) REFERENCES users (id, org_id) ON DELETE CASCADE,
|
|
62
|
+
|
|
63
|
+
-- Two authenticators called the same thing is a person unable to tell which
|
|
64
|
+
-- one they are removing.
|
|
65
|
+
UNIQUE (user_id, kind, label)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE INDEX ON user_second_factors (user_id) WHERE confirmed_at IS NOT NULL;
|
|
69
|
+
|
|
70
|
+
-- ─────────── the way back in ───────────
|
|
71
|
+
--
|
|
72
|
+
-- One row per code, spent once. They are the whole of the answer for the
|
|
73
|
+
-- account this installation cannot e-mail: the platform administrator has no
|
|
74
|
+
-- organization to administer, and a deployment with no SMTP configured has no
|
|
75
|
+
-- recovery link for anybody.
|
|
76
|
+
--
|
|
77
|
+
-- Hashed with SHA-256 and deliberately **not** with scrypt. A recovery code is
|
|
78
|
+
-- 128 bits from the CSPRNG, so there is no dictionary to slow down and no
|
|
79
|
+
-- password to protect — the cost parameter would buy nothing and would make
|
|
80
|
+
-- issuing ten of them a second of CPU on a request.
|
|
81
|
+
CREATE TABLE user_recovery_codes (
|
|
82
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
83
|
+
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
|
84
|
+
user_id uuid NOT NULL,
|
|
85
|
+
code_hash text NOT NULL,
|
|
86
|
+
used_at timestamptz,
|
|
87
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
88
|
+
|
|
89
|
+
FOREIGN KEY (user_id, org_id) REFERENCES users (id, org_id) ON DELETE CASCADE,
|
|
90
|
+
|
|
91
|
+
-- Within one installation a code is unique, so spending one is a single
|
|
92
|
+
-- UPDATE with no read in front of it that another request could race.
|
|
93
|
+
UNIQUE (code_hash)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
CREATE INDEX ON user_recovery_codes (user_id) WHERE used_at IS NULL;
|
|
97
|
+
|
|
98
|
+
-- ─────────── second line of defense ───────────
|
|
99
|
+
ALTER TABLE user_second_factors ENABLE ROW LEVEL SECURITY;
|
|
100
|
+
ALTER TABLE user_second_factors FORCE ROW LEVEL SECURITY;
|
|
101
|
+
ALTER TABLE user_recovery_codes ENABLE ROW LEVEL SECURITY;
|
|
102
|
+
ALTER TABLE user_recovery_codes FORCE ROW LEVEL SECURITY;
|
|
103
|
+
|
|
104
|
+
CREATE POLICY org_isolation ON user_second_factors USING (org_id = current_setting('app.current_org')::uuid);
|
|
105
|
+
CREATE POLICY org_isolation ON user_recovery_codes USING (org_id = current_setting('app.current_org')::uuid);
|
|
106
|
+
|
|
107
|
+
-- The application reads and writes both; nothing else does. No grant to
|
|
108
|
+
-- `nacre_worker`: the worker has no question about who signed in, and a
|
|
109
|
+
-- privilege nothing reads is the shape this repository keeps removing.
|
|
110
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON user_second_factors TO nacre_app;
|
|
111
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON user_recovery_codes TO nacre_app;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
-- 0030 — a password somebody can recover, without a `psql` session.
|
|
2
|
+
--
|
|
3
|
+
-- `POST /v1/users/{id}/password` has existed since there were users, and it is
|
|
4
|
+
-- an *administrator* setting somebody else's. The person who forgot theirs at
|
|
5
|
+
-- the weekend had no route at all, and on a single-administrator installation
|
|
6
|
+
-- — which the open core mostly is — the administrator who forgets theirs had
|
|
7
|
+
-- no route that did not go through the database.
|
|
8
|
+
--
|
|
9
|
+
-- The specification is docs/api.md, "Recovering a password".
|
|
10
|
+
|
|
11
|
+
-- ─────────── the token ───────────
|
|
12
|
+
--
|
|
13
|
+
-- One row per issued link, spent once.
|
|
14
|
+
--
|
|
15
|
+
-- **No cross-tenant read, and that is why the token carries its organization.**
|
|
16
|
+
-- Resolving a credential outside `withOrg` has exactly two mechanisms here and
|
|
17
|
+
-- both are narrow on purpose — `0008`'s own words are that `users` gets neither
|
|
18
|
+
-- "as a decision rather than an omission". A token shaped `<org_id>.<secret>`
|
|
19
|
+
-- means redemption knows the organization before it reads anything, so this
|
|
20
|
+
-- table is read through `withOrg` like every other, and nothing here opens a
|
|
21
|
+
-- second door into a table that a stranger can reach unauthenticated.
|
|
22
|
+
--
|
|
23
|
+
-- The organization id is not a secret from the person holding the link: it is
|
|
24
|
+
-- in their own `/v1/me`. What is secret is the half beside it.
|
|
25
|
+
CREATE TABLE password_reset_tokens (
|
|
26
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
27
|
+
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
|
28
|
+
user_id uuid NOT NULL,
|
|
29
|
+
|
|
30
|
+
-- SHA-256 of the whole token. Fast on purpose: the secret half is 32 bytes
|
|
31
|
+
-- from the CSPRNG, so there is no dictionary to slow down — the same
|
|
32
|
+
-- reasoning the recovery codes in 0029 carry, and the opposite of a password.
|
|
33
|
+
token_hash text NOT NULL,
|
|
34
|
+
|
|
35
|
+
expires_at timestamptz NOT NULL,
|
|
36
|
+
-- Spent by the UPDATE that finds it, so two requests cannot spend one.
|
|
37
|
+
used_at timestamptz,
|
|
38
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
39
|
+
|
|
40
|
+
FOREIGN KEY (user_id, org_id) REFERENCES users (id, org_id) ON DELETE CASCADE,
|
|
41
|
+
|
|
42
|
+
-- Unique across the installation, so redemption is one statement with no read
|
|
43
|
+
-- in front of it that another request could race.
|
|
44
|
+
UNIQUE (token_hash)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
-- The sweep reads by expiry; redemption reads by hash, which the constraint
|
|
48
|
+
-- above already indexes.
|
|
49
|
+
CREATE INDEX ON password_reset_tokens (expires_at);
|
|
50
|
+
|
|
51
|
+
ALTER TABLE password_reset_tokens ENABLE ROW LEVEL SECURITY;
|
|
52
|
+
ALTER TABLE password_reset_tokens FORCE ROW LEVEL SECURITY;
|
|
53
|
+
CREATE POLICY org_isolation ON password_reset_tokens
|
|
54
|
+
USING (org_id = current_setting('app.current_org')::uuid);
|
|
55
|
+
|
|
56
|
+
-- The application issues, spends and prunes them. Not granted to
|
|
57
|
+
-- `nacre_worker`: the worker has no question about who is signing in, and a
|
|
58
|
+
-- privilege nothing reads is the shape this repository keeps removing.
|
|
59
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON password_reset_tokens TO nacre_app;
|
package/dist/passwords.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"passwords.d.ts","sourceRoot":"","sources":["../passwords.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"passwords.d.ts","sourceRoot":"","sources":["../passwords.ts"],"names":[],"mappings":"AA4GA,iFAAiF;AACjF,qBAAa,OAAQ,SAAQ,KAAK;;CAKjC;AAyCD,iEAAiE;AACjE,eAAO,MAAM,WAAW,QAAO;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAI5E,CAAA;AAEF;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAWpE;AAmCD;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAexF;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAIpD;AAgBD,wBAAsB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAI5E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,cAAc,qsBAUjB,CAAA;AAEV,qCAAqC;AACrC,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAKpC;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB,QACgD,CAAA;AAElF,wBAAgB,gBAAgB,IAAI,MAAM,CAQzC"}
|
package/dist/passwords.js
CHANGED
|
@@ -76,6 +76,14 @@ const MAX_MEM = 256 * 1024 * 1024;
|
|
|
76
76
|
* Refusing is not an oracle. It depends on how loaded the process is and not at
|
|
77
77
|
* all on whether the account exists, and it is returned identically to the
|
|
78
78
|
* caller whether the address matched a user or not.
|
|
79
|
+
*
|
|
80
|
+
* The 503 is `errors.ts`'s `tooBusy`, sent from the two error boundaries in
|
|
81
|
+
* `server.ts` — sign-in is reached without a credential and everything else
|
|
82
|
+
* with one, so a single catch cannot cover both. That is worth naming here
|
|
83
|
+
* because this paragraph claimed "the caller answers 503" while exactly one of
|
|
84
|
+
* the four routes that reach this gate did: the other three — creating a user,
|
|
85
|
+
* an administrator resetting a password, and redeeming a recovery link — turned
|
|
86
|
+
* a loaded process into a 500.
|
|
79
87
|
*/
|
|
80
88
|
const poolSize = Number(process.env.UV_THREADPOOL_SIZE ?? 4);
|
|
81
89
|
const MAX_CONCURRENT = Math.max(1, Math.floor((Number.isFinite(poolSize) ? poolSize : 4) / 2));
|
package/dist/passwords.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"passwords.js","sourceRoot":"","sources":["../passwords.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,SAAS,EACT,MAAM,IAAI,cAAc,EACxB,eAAe,GAEhB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAErC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,8EAA8E;AAC9E,iDAAiD;AACjD,MAAM,MAAM,GAAG,SAAS,CAAC,cAAc,CAKnB,CAAA;AAEpB,oDAAoD;AACpD,MAAM,CAAC,GAAG,OAAO,CAAA;AACjB,MAAM,CAAC,GAAG,CAAC,CAAA;AACX,MAAM,CAAC,GAAG,CAAC,CAAA;AACX,MAAM,SAAS,GAAG,EAAE,CAAA;AACpB,MAAM,UAAU,GAAG,EAAE,CAAA;AAErB;;;;;GAKG;AACH,MAAM,OAAO,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAA;AAEjC
|
|
1
|
+
{"version":3,"file":"passwords.js","sourceRoot":"","sources":["../passwords.ts"],"names":[],"mappings":"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"}
|
package/dist/totp.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** RFC 6238's default and what every authenticator assumes. */
|
|
2
|
+
export declare const TOTP_PERIOD_SECONDS = 30;
|
|
3
|
+
/** Six, for the same reason: it is what the apps show. */
|
|
4
|
+
export declare const TOTP_DIGITS = 6;
|
|
5
|
+
/**
|
|
6
|
+
* How far either side of now a code is accepted.
|
|
7
|
+
*
|
|
8
|
+
* One step, which is up to sixty seconds of tolerance in the worst alignment.
|
|
9
|
+
* Phones drift and people type slowly; two steps is the number that starts
|
|
10
|
+
* making a shoulder-surfed code useful for longer than it should be.
|
|
11
|
+
*/
|
|
12
|
+
export declare const TOTP_SKEW_STEPS = 1;
|
|
13
|
+
export declare function base32Encode(bytes: Uint8Array): string;
|
|
14
|
+
export declare function base32Decode(text: string): Buffer;
|
|
15
|
+
/**
|
|
16
|
+
* 160 bits, which is what RFC 4226 recommends and what the apps expect.
|
|
17
|
+
*
|
|
18
|
+
* Shorter secrets are accepted by every authenticator and are the reason a
|
|
19
|
+
* generator is here rather than left to a call site: 80 bits is legal, common
|
|
20
|
+
* in examples, and half the strength of the thing it is protecting.
|
|
21
|
+
*/
|
|
22
|
+
export declare function generateTotpSecret(): string;
|
|
23
|
+
/** The step a moment falls in. Exported because the replay bound is a step. */
|
|
24
|
+
export declare const totpStep: (at?: Date) => number;
|
|
25
|
+
/** RFC 6238 over RFC 4226: HMAC the counter, take the dynamic offset, mod 10^d. */
|
|
26
|
+
export declare function totpCode(secret: string, step: number): string;
|
|
27
|
+
export interface TotpVerification {
|
|
28
|
+
/** The step the code belonged to, to be stored so it cannot be spent twice. */
|
|
29
|
+
readonly step: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Check a code, and say which step it was.
|
|
33
|
+
*
|
|
34
|
+
* `after` is the last step this factor already accepted, and a code at or
|
|
35
|
+
* before it is refused however correct it is. Without that, a code is good for
|
|
36
|
+
* the whole window it was shown in — so somebody who reads it over a shoulder,
|
|
37
|
+
* or a phishing page that relays it, gets a second use out of it. The caller
|
|
38
|
+
* stores the returned step.
|
|
39
|
+
*/
|
|
40
|
+
export declare function verifyTotp(secret: string, code: string, options?: {
|
|
41
|
+
readonly at?: Date;
|
|
42
|
+
readonly after?: number | null;
|
|
43
|
+
}): TotpVerification | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The URL an authenticator reads out of a QR code.
|
|
46
|
+
*
|
|
47
|
+
* The label carries the issuer as well as the account, which is what makes two
|
|
48
|
+
* installations distinguishable in a list of thirty entries — an app shows the
|
|
49
|
+
* label, and "dana@example.com" alone is four identical rows.
|
|
50
|
+
*/
|
|
51
|
+
export declare function otpauthUrl(options: {
|
|
52
|
+
readonly issuer: string;
|
|
53
|
+
readonly account: string;
|
|
54
|
+
readonly secret: string;
|
|
55
|
+
}): string;
|
|
56
|
+
export declare function sealTotpSecret(secret: string, key: Buffer): string;
|
|
57
|
+
export declare function openTotpSecret(sealed: string, key: Buffer): string;
|
|
58
|
+
/** Ten, which is what a person can print on one line each and lose half of. */
|
|
59
|
+
export declare const RECOVERY_CODE_COUNT = 10;
|
|
60
|
+
/**
|
|
61
|
+
* A recovery code, in the shape people retype correctly.
|
|
62
|
+
*
|
|
63
|
+
* Four groups of five from a 32-character alphabet is 100 bits — far past
|
|
64
|
+
* anything guessable — and the groups exist because a twenty-character run
|
|
65
|
+
* gets transcribed wrongly and blamed on the product.
|
|
66
|
+
*/
|
|
67
|
+
export declare function generateRecoveryCode(): string;
|
|
68
|
+
export declare const normalizeRecoveryCode: (code: string) => string;
|
|
69
|
+
/**
|
|
70
|
+
* Hashed with SHA-256 and deliberately not with scrypt.
|
|
71
|
+
*
|
|
72
|
+
* A recovery code is 100 bits from the CSPRNG, so there is no dictionary to
|
|
73
|
+
* slow down and no human-chosen password to protect. A cost parameter would buy
|
|
74
|
+
* nothing against an attacker and would make issuing ten of them a second of
|
|
75
|
+
* CPU on a request — on the same libuv pool the sign-in path already has to be
|
|
76
|
+
* careful about.
|
|
77
|
+
*/
|
|
78
|
+
export declare function hashRecoveryCode(code: string): string;
|
|
79
|
+
//# sourceMappingURL=totp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp.d.ts","sourceRoot":"","sources":["../totp.ts"],"names":[],"mappings":"AA8BA,+DAA+D;AAC/D,eAAO,MAAM,mBAAmB,KAAK,CAAA;AACrC,0DAA0D;AAC1D,eAAO,MAAM,WAAW,IAAI,CAAA;AAE5B;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,IAAI,CAAA;AAKhC,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CActD;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAoBjD;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,+EAA+E;AAC/E,eAAO,MAAM,QAAQ,GAAI,KAAI,IAAiB,KAAG,MACM,CAAA;AAEvD,mFAAmF;AACnF,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ7D;AAED,MAAM,WAAW,gBAAgB;IAC/B,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAO,GACnE,gBAAgB,GAAG,SAAS,CAe9B;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB,GAAG,MAAM,CAUT;AAiBD,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAMlE;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAQlE;AAID,+EAA+E;AAC/E,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC;;;;;;GAMG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,CAI7C;AAED,eAAO,MAAM,qBAAqB,GAAI,MAAM,MAAM,KAAG,MACT,CAAA;AAE5C;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAErD"}
|
package/dist/totp.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A second factor, and the codes that get you back in without one.
|
|
3
|
+
*
|
|
4
|
+
* ## What this decides, and what it does not
|
|
5
|
+
*
|
|
6
|
+
* A second factor decides whether a **session starts**. It grants nothing: the
|
|
7
|
+
* permitted set is still computed per request from `grants`, and a token minted
|
|
8
|
+
* after a correct code reaches exactly what the same token reaches without one.
|
|
9
|
+
* Nothing in `authz/` reads anything here, deliberately — a factor that could
|
|
10
|
+
* widen access would be a second answer to the question this product exists to
|
|
11
|
+
* answer once.
|
|
12
|
+
*
|
|
13
|
+
* ## Why SHA-1
|
|
14
|
+
*
|
|
15
|
+
* RFC 6238 leaves the hash open and every authenticator on a phone implements
|
|
16
|
+
* SHA-1; several implement nothing else. The weakness SHA-1 is retired for is
|
|
17
|
+
* collision resistance, which HMAC does not rest on — and the key here is 160
|
|
18
|
+
* bits from the CSPRNG while the message is a counter and the answer lives
|
|
19
|
+
* thirty seconds. Choosing SHA-256 would buy nothing measurable and would meet
|
|
20
|
+
* a person whose authenticator shows six digits that never work.
|
|
21
|
+
*
|
|
22
|
+
* ## Standard library only
|
|
23
|
+
*
|
|
24
|
+
* The same argument the parser makes: this module is on the authentication
|
|
25
|
+
* path, and a dependency here is a dependency inside every sign-in. The whole
|
|
26
|
+
* of it is HMAC, a counter, a base32 alphabet and AES-GCM, all of which
|
|
27
|
+
* `node:crypto` already has.
|
|
28
|
+
*/
|
|
29
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
30
|
+
/** RFC 6238's default and what every authenticator assumes. */
|
|
31
|
+
export const TOTP_PERIOD_SECONDS = 30;
|
|
32
|
+
/** Six, for the same reason: it is what the apps show. */
|
|
33
|
+
export const TOTP_DIGITS = 6;
|
|
34
|
+
/**
|
|
35
|
+
* How far either side of now a code is accepted.
|
|
36
|
+
*
|
|
37
|
+
* One step, which is up to sixty seconds of tolerance in the worst alignment.
|
|
38
|
+
* Phones drift and people type slowly; two steps is the number that starts
|
|
39
|
+
* making a shoulder-surfed code useful for longer than it should be.
|
|
40
|
+
*/
|
|
41
|
+
export const TOTP_SKEW_STEPS = 1;
|
|
42
|
+
/** RFC 4648 base32, which is the only encoding an authenticator will read. */
|
|
43
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
44
|
+
export function base32Encode(bytes) {
|
|
45
|
+
let bits = 0;
|
|
46
|
+
let value = 0;
|
|
47
|
+
let out = '';
|
|
48
|
+
for (const byte of bytes) {
|
|
49
|
+
value = (value << 8) | byte;
|
|
50
|
+
bits += 8;
|
|
51
|
+
while (bits >= 5) {
|
|
52
|
+
out += ALPHABET[(value >>> (bits - 5)) & 31];
|
|
53
|
+
bits -= 5;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (bits > 0)
|
|
57
|
+
out += ALPHABET[(value << (5 - bits)) & 31];
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
export function base32Decode(text) {
|
|
61
|
+
// Padding and case are both things a person retypes wrongly, and neither
|
|
62
|
+
// carries information. Whitespace goes first: with the order reversed, a
|
|
63
|
+
// value ending in `== ` keeps its padding, because it is no longer at the
|
|
64
|
+
// end — which a test caught on the first run.
|
|
65
|
+
const clean = text.replace(/\s+/gu, '').replace(/=+$/u, '').toUpperCase();
|
|
66
|
+
let bits = 0;
|
|
67
|
+
let value = 0;
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const character of clean) {
|
|
70
|
+
const index = ALPHABET.indexOf(character);
|
|
71
|
+
if (index === -1)
|
|
72
|
+
throw new Error(`not base32: ${character}`);
|
|
73
|
+
value = (value << 5) | index;
|
|
74
|
+
bits += 5;
|
|
75
|
+
if (bits >= 8) {
|
|
76
|
+
out.push((value >>> (bits - 8)) & 0xff);
|
|
77
|
+
bits -= 8;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return Buffer.from(out);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 160 bits, which is what RFC 4226 recommends and what the apps expect.
|
|
84
|
+
*
|
|
85
|
+
* Shorter secrets are accepted by every authenticator and are the reason a
|
|
86
|
+
* generator is here rather than left to a call site: 80 bits is legal, common
|
|
87
|
+
* in examples, and half the strength of the thing it is protecting.
|
|
88
|
+
*/
|
|
89
|
+
export function generateTotpSecret() {
|
|
90
|
+
return base32Encode(randomBytes(20));
|
|
91
|
+
}
|
|
92
|
+
/** The step a moment falls in. Exported because the replay bound is a step. */
|
|
93
|
+
export const totpStep = (at = new Date()) => Math.floor(at.getTime() / 1000 / TOTP_PERIOD_SECONDS);
|
|
94
|
+
/** RFC 6238 over RFC 4226: HMAC the counter, take the dynamic offset, mod 10^d. */
|
|
95
|
+
export function totpCode(secret, step) {
|
|
96
|
+
const counter = Buffer.alloc(8);
|
|
97
|
+
counter.writeBigUInt64BE(BigInt(step));
|
|
98
|
+
const mac = createHmac('sha1', base32Decode(secret)).update(counter).digest();
|
|
99
|
+
const offset = mac[mac.length - 1] & 0x0f;
|
|
100
|
+
const binary = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3];
|
|
101
|
+
return String(binary % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, '0');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Check a code, and say which step it was.
|
|
105
|
+
*
|
|
106
|
+
* `after` is the last step this factor already accepted, and a code at or
|
|
107
|
+
* before it is refused however correct it is. Without that, a code is good for
|
|
108
|
+
* the whole window it was shown in — so somebody who reads it over a shoulder,
|
|
109
|
+
* or a phishing page that relays it, gets a second use out of it. The caller
|
|
110
|
+
* stores the returned step.
|
|
111
|
+
*/
|
|
112
|
+
export function verifyTotp(secret, code, options = {}) {
|
|
113
|
+
const typed = code.replace(/\s+/gu, '');
|
|
114
|
+
if (!new RegExp(`^[0-9]{${String(TOTP_DIGITS)}}$`, 'u').test(typed))
|
|
115
|
+
return undefined;
|
|
116
|
+
const current = totpStep(options.at ?? new Date());
|
|
117
|
+
for (let offset = -TOTP_SKEW_STEPS; offset <= TOTP_SKEW_STEPS; offset += 1) {
|
|
118
|
+
const step = current + offset;
|
|
119
|
+
if (options.after !== undefined && options.after !== null && step <= options.after)
|
|
120
|
+
continue;
|
|
121
|
+
const expected = Buffer.from(totpCode(secret, step));
|
|
122
|
+
const given = Buffer.from(typed);
|
|
123
|
+
// Constant time, because the comparison is over a secret-derived value and
|
|
124
|
+
// an attacker controls one side of it.
|
|
125
|
+
if (expected.length === given.length && timingSafeEqual(expected, given))
|
|
126
|
+
return { step };
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The URL an authenticator reads out of a QR code.
|
|
132
|
+
*
|
|
133
|
+
* The label carries the issuer as well as the account, which is what makes two
|
|
134
|
+
* installations distinguishable in a list of thirty entries — an app shows the
|
|
135
|
+
* label, and "dana@example.com" alone is four identical rows.
|
|
136
|
+
*/
|
|
137
|
+
export function otpauthUrl(options) {
|
|
138
|
+
const label = `${encodeURIComponent(options.issuer)}:${encodeURIComponent(options.account)}`;
|
|
139
|
+
const query = new URLSearchParams({
|
|
140
|
+
secret: options.secret,
|
|
141
|
+
issuer: options.issuer,
|
|
142
|
+
algorithm: 'SHA1',
|
|
143
|
+
digits: String(TOTP_DIGITS),
|
|
144
|
+
period: String(TOTP_PERIOD_SECONDS),
|
|
145
|
+
});
|
|
146
|
+
return `otpauth://totp/${label}?${query.toString()}`;
|
|
147
|
+
}
|
|
148
|
+
// ── the secret at rest ───────────────────────────────────────────────────────
|
|
149
|
+
/**
|
|
150
|
+
* Sealed with AES-256-GCM under a key this process is given.
|
|
151
|
+
*
|
|
152
|
+
* A TOTP secret in the clear is a second factor that a database dump defeats,
|
|
153
|
+
* which is the same blast-radius argument that made the token signing key
|
|
154
|
+
* asymmetric: the point of the factor is that stealing one thing is not enough.
|
|
155
|
+
*
|
|
156
|
+
* The nonce and the tag travel inside the stored value the way `password_hash`
|
|
157
|
+
* carries its scrypt parameters — a column that needs a second column to be
|
|
158
|
+
* readable is two things that can come apart.
|
|
159
|
+
*/
|
|
160
|
+
const SEALED = 'v1';
|
|
161
|
+
export function sealTotpSecret(secret, key) {
|
|
162
|
+
if (key.length !== 32)
|
|
163
|
+
throw new Error('a 2FA key is 32 bytes');
|
|
164
|
+
const nonce = randomBytes(12);
|
|
165
|
+
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
|
166
|
+
const body = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
|
|
167
|
+
return [SEALED, nonce.toString('base64url'), body.toString('base64url'), cipher.getAuthTag().toString('base64url')].join('.');
|
|
168
|
+
}
|
|
169
|
+
export function openTotpSecret(sealed, key) {
|
|
170
|
+
const [version, nonce, body, tag] = sealed.split('.');
|
|
171
|
+
if (version !== SEALED || nonce === undefined || body === undefined || tag === undefined) {
|
|
172
|
+
throw new Error('not a sealed TOTP secret');
|
|
173
|
+
}
|
|
174
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'base64url'));
|
|
175
|
+
decipher.setAuthTag(Buffer.from(tag, 'base64url'));
|
|
176
|
+
return Buffer.concat([decipher.update(Buffer.from(body, 'base64url')), decipher.final()]).toString('utf8');
|
|
177
|
+
}
|
|
178
|
+
// ── recovery codes ───────────────────────────────────────────────────────────
|
|
179
|
+
/** Ten, which is what a person can print on one line each and lose half of. */
|
|
180
|
+
export const RECOVERY_CODE_COUNT = 10;
|
|
181
|
+
/**
|
|
182
|
+
* A recovery code, in the shape people retype correctly.
|
|
183
|
+
*
|
|
184
|
+
* Four groups of five from a 32-character alphabet is 100 bits — far past
|
|
185
|
+
* anything guessable — and the groups exist because a twenty-character run
|
|
186
|
+
* gets transcribed wrongly and blamed on the product.
|
|
187
|
+
*/
|
|
188
|
+
export function generateRecoveryCode() {
|
|
189
|
+
const bytes = randomBytes(20);
|
|
190
|
+
const text = base32Encode(bytes).slice(0, 20);
|
|
191
|
+
return [text.slice(0, 5), text.slice(5, 10), text.slice(10, 15), text.slice(15, 20)].join('-');
|
|
192
|
+
}
|
|
193
|
+
export const normalizeRecoveryCode = (code) => code.replace(/[\s-]+/gu, '').toUpperCase();
|
|
194
|
+
/**
|
|
195
|
+
* Hashed with SHA-256 and deliberately not with scrypt.
|
|
196
|
+
*
|
|
197
|
+
* A recovery code is 100 bits from the CSPRNG, so there is no dictionary to
|
|
198
|
+
* slow down and no human-chosen password to protect. A cost parameter would buy
|
|
199
|
+
* nothing against an attacker and would make issuing ten of them a second of
|
|
200
|
+
* CPU on a request — on the same libuv pool the sign-in path already has to be
|
|
201
|
+
* careful about.
|
|
202
|
+
*/
|
|
203
|
+
export function hashRecoveryCode(code) {
|
|
204
|
+
return createHash('sha256').update(normalizeRecoveryCode(code)).digest('hex');
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=totp.js.map
|
package/dist/totp.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp.js","sourceRoot":"","sources":["../totp.ts"],"names":[],"mappings":"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"}
|