@nacre.work/core 0.17.5 → 0.19.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/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- 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/migrations/0031_webauthn.sql +123 -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/dist/webauthn.d.ts +139 -0
- package/dist/webauthn.d.ts.map +1 -0
- package/dist/webauthn.js +410 -0
- package/dist/webauthn.js.map +1 -0
- package/package.json +3 -1
|
@@ -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;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
-- 0031 — a second factor you cannot be phished out of.
|
|
2
|
+
--
|
|
3
|
+
-- TOTP bounds a stolen password and does not bound a convincing page: six
|
|
4
|
+
-- digits typed into `nacre-work.com` work exactly as well there as here, and
|
|
5
|
+
-- the person who typed them has no way to know. WebAuthn is the factor whose
|
|
6
|
+
-- signature covers the origin, so an assertion produced for the wrong site does
|
|
7
|
+
-- not verify at the right one — that is the whole reason to carry a second kind
|
|
8
|
+
-- rather than more of the first.
|
|
9
|
+
--
|
|
10
|
+
-- Everything docs/authz.md says about a second factor is unchanged and applies
|
|
11
|
+
-- to this one: it decides whether a session starts, it grants nothing, it is
|
|
12
|
+
-- enrolled per person and never on their behalf, and a service account and a
|
|
13
|
+
-- delegation are refused. Nothing in packages/core/authz reads any of it.
|
|
14
|
+
|
|
15
|
+
-- ─────────── the kind ───────────
|
|
16
|
+
--
|
|
17
|
+
-- 0029 wrote `CHECK (kind IN ('totp'))` and said in its own comment that
|
|
18
|
+
-- WebAuthn would widen it "in its own migration rather than being written here
|
|
19
|
+
-- on speculation". This is that migration.
|
|
20
|
+
ALTER TABLE user_second_factors DROP CONSTRAINT user_second_factors_kind_check;
|
|
21
|
+
ALTER TABLE user_second_factors ADD CONSTRAINT user_second_factors_kind_check
|
|
22
|
+
CHECK (kind IN ('totp', 'webauthn'));
|
|
23
|
+
|
|
24
|
+
-- ─────────── what a WebAuthn factor is made of ───────────
|
|
25
|
+
--
|
|
26
|
+
-- A TOTP factor is a sealed shared secret; a WebAuthn factor is a public key
|
|
27
|
+
-- and has no secret at all. That asymmetry is the feature — a database dump
|
|
28
|
+
-- hands over nothing that can produce an assertion — and it is why `secret`
|
|
29
|
+
-- becomes nullable below rather than these columns being squeezed into it.
|
|
30
|
+
ALTER TABLE user_second_factors
|
|
31
|
+
-- The credential the authenticator minted, base64url as the browser reports
|
|
32
|
+
-- it. Bytes would be tidier in the abstract and worse here: every comparison
|
|
33
|
+
-- this column takes part in arrives as the string a browser sent.
|
|
34
|
+
ADD COLUMN credential_id text,
|
|
35
|
+
|
|
36
|
+
-- The public key, as a JWK. COSE and JWK are the same parameters under
|
|
37
|
+
-- different names, and `node:crypto` imports the second directly — so storing
|
|
38
|
+
-- the converted form means the verification path does no parsing at all on a
|
|
39
|
+
-- value that has already been through it once.
|
|
40
|
+
ADD COLUMN public_key jsonb,
|
|
41
|
+
|
|
42
|
+
-- The COSE algorithm: -7 ES256, -257 RS256, -8 EdDSA. Kept beside the key
|
|
43
|
+
-- rather than derived from it, because "which algorithm did this credential
|
|
44
|
+
-- register with" is a question about the registration and not about the
|
|
45
|
+
-- key's shape — and an RSA key alone does not say whether PKCS#1 or PSS was
|
|
46
|
+
-- agreed.
|
|
47
|
+
ADD COLUMN alg integer,
|
|
48
|
+
|
|
49
|
+
-- The authenticator's own counter. A value that goes backwards is the one
|
|
50
|
+
-- signal WebAuthn offers for a cloned authenticator. Most platform
|
|
51
|
+
-- authenticators never move it, which is allowed and is why this is not
|
|
52
|
+
-- NOT NULL DEFAULT 0 with a monotonic constraint — see the verifier.
|
|
53
|
+
ADD COLUMN sign_count bigint;
|
|
54
|
+
|
|
55
|
+
-- A TOTP factor has a secret and no key; a WebAuthn factor has a key and no
|
|
56
|
+
-- secret. Stated as a constraint rather than left to the application, because
|
|
57
|
+
-- the application is one caller and a row with neither is a factor that can
|
|
58
|
+
-- never answer.
|
|
59
|
+
ALTER TABLE user_second_factors ALTER COLUMN secret DROP NOT NULL;
|
|
60
|
+
ALTER TABLE user_second_factors ADD CONSTRAINT user_second_factors_shape CHECK (
|
|
61
|
+
(kind = 'totp' AND secret IS NOT NULL AND credential_id IS NULL AND public_key IS NULL AND alg IS NULL)
|
|
62
|
+
OR
|
|
63
|
+
(kind = 'webauthn' AND secret IS NULL AND credential_id IS NOT NULL AND public_key IS NOT NULL AND alg IS NOT NULL)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
-- Within one organization a credential id names one factor. Not installation
|
|
67
|
+
-- wide, and that is the point: an assertion here is the *second* step of a
|
|
68
|
+
-- sign-in, so the organization and the person are already known and the lookup
|
|
69
|
+
-- reads through `withOrg` like everything else. A globally unique index would
|
|
70
|
+
-- be a cross-tenant read waiting for somebody to write the query.
|
|
71
|
+
CREATE UNIQUE INDEX user_second_factors_credential
|
|
72
|
+
ON user_second_factors (org_id, credential_id)
|
|
73
|
+
WHERE credential_id IS NOT NULL;
|
|
74
|
+
|
|
75
|
+
-- ─────────── the challenge ───────────
|
|
76
|
+
--
|
|
77
|
+
-- Every ceremony takes a challenge this server issued, and it has to be
|
|
78
|
+
-- **single-use**: the signature covers it, so an assertion captured on the wire
|
|
79
|
+
-- is replayable for as long as its challenge is. Nothing else in the ceremony
|
|
80
|
+
-- stops that.
|
|
81
|
+
--
|
|
82
|
+
-- A table rather than a signed, stateless value. A stateless challenge would
|
|
83
|
+
-- have to be spent somewhere anyway to be single-use, and a store that only
|
|
84
|
+
-- prevents replay is the same store as one that also carries the value — with
|
|
85
|
+
-- the difference that this one can be read to see what is outstanding.
|
|
86
|
+
CREATE TABLE webauthn_challenges (
|
|
87
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
88
|
+
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
|
89
|
+
user_id uuid NOT NULL,
|
|
90
|
+
|
|
91
|
+
-- 'register' or 'authenticate'. A challenge issued for enrolment must not be
|
|
92
|
+
-- spendable on a sign-in: the first is asked for by somebody already signed
|
|
93
|
+
-- in, and letting the two share a pool would let a session mint the input to
|
|
94
|
+
-- a ceremony it is not in.
|
|
95
|
+
purpose text NOT NULL CHECK (purpose IN ('register', 'authenticate')),
|
|
96
|
+
|
|
97
|
+
-- base64url, exactly as it goes into `clientDataJSON` and comes back out, so
|
|
98
|
+
-- the comparison is a string equality and never an encoding round trip.
|
|
99
|
+
challenge text NOT NULL,
|
|
100
|
+
|
|
101
|
+
expires_at timestamptz NOT NULL,
|
|
102
|
+
used_at timestamptz,
|
|
103
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
104
|
+
|
|
105
|
+
FOREIGN KEY (user_id, org_id) REFERENCES users (id, org_id) ON DELETE CASCADE,
|
|
106
|
+
|
|
107
|
+
-- Spending one is a single UPDATE with no read in front of it that another
|
|
108
|
+
-- request could race, which is the same shape `password_reset_tokens` uses.
|
|
109
|
+
UNIQUE (org_id, challenge)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE INDEX ON webauthn_challenges (expires_at) WHERE used_at IS NULL;
|
|
113
|
+
|
|
114
|
+
-- ─────────── second line of defense ───────────
|
|
115
|
+
ALTER TABLE webauthn_challenges ENABLE ROW LEVEL SECURITY;
|
|
116
|
+
ALTER TABLE webauthn_challenges FORCE ROW LEVEL SECURITY;
|
|
117
|
+
CREATE POLICY org_isolation ON webauthn_challenges USING (org_id = current_setting('app.current_org')::uuid);
|
|
118
|
+
|
|
119
|
+
-- The application reads and writes it; nothing else does. No grant to
|
|
120
|
+
-- `nacre_worker`, for the reason 0029 gives: the worker has no question about
|
|
121
|
+
-- who signed in, and a privilege nothing reads is the shape this repository
|
|
122
|
+
-- keeps removing.
|
|
123
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON webauthn_challenges 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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAEpH,+DAA+D;AAC/D,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAA;AACrC,0DAA0D;AAC1D,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,CAAA;AAE5B;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,CAAA;AAEhC,8EAA8E;AAC9E,MAAM,QAAQ,GAAG,kCAAkC,CAAA;AAEnD,MAAM,UAAU,YAAY,CAAC,KAAiB;IAC5C,IAAI,IAAI,GAAG,CAAC,CAAA;IACZ,IAAI,KAAK,GAAG,CAAC,CAAA;IACb,IAAI,GAAG,GAAG,EAAE,CAAA;IACZ,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,KAAK,GAAG,CAAC,KAAK,IAAI,CAAC,CAAC,GAAG,IAAI,CAAA;QAC3B,IAAI,IAAI,CAAC,CAAA;QACT,OAAO,IAAI,IAAI,CAAC,EAAE,CAAC;YACjB,GAAG,IAAI,QAAQ,CAAC,CAAC,KAAK,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAA;YAC5C,IAAI,IAAI,CAAC,CAAA;QACX,CAAC;IACH,CAAC;IACD,IAAI,IAAI,GAAG,CAAC;QAAE,GAAG,IAAI,QAAQ,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,EAAE,CAAC,CAAA;IACzD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,yEAAyE;IACzE,yEAAyE;IACzE,0EAA0E;IAC1E,8CAA8C;IAC9C,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAA;IACzE,IAAI,IAAI,GAAG,CAAC,CAAA;IACZ,IAAI,KAAK,GAAG,CAAC,CAAA;IACb,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,KAAK,MAAM,SAAS,IAAI,KAAK,EAAE,CAAC;QAC9B,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;QACzC,IAAI,KAAK,KAAK,CAAC,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,eAAe,SAAS,EAAE,CAAC,CAAA;QAC7D,KAAK,GAAG,CAAC,KAAK,IAAI,CAAC,CAAC,GAAG,KAAK,CAAA;QAC5B,IAAI,IAAI,CAAC,CAAA;QACT,IAAI,IAAI,IAAI,CAAC,EAAE,CAAC;YACd,GAAG,CAAC,IAAI,CAAC,CAAC,KAAK,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;YACvC,IAAI,IAAI,CAAC,CAAA;QACX,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AACzB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB;IAChC,OAAO,YAAY,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAA;AACtC,CAAC;AAED,+EAA+E;AAC/E,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,KAAW,IAAI,IAAI,EAAE,EAAU,EAAE,CACxD,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,IAAI,GAAG,mBAAmB,CAAC,CAAA;AAEvD,mFAAmF;AACnF,MAAM,UAAU,QAAQ,CAAC,MAAc,EAAE,IAAY;IACnD,MAAM,OAAO,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IAC/B,OAAO,CAAC,gBAAgB,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAA;IACtC,MAAM,GAAG,GAAG,UAAU,CAAC,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,CAAA;IAC7E,MAAM,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAE,GAAG,IAAI,CAAA;IAC1C,MAAM,MAAM,GACV,CAAC,CAAC,GAAG,CAAC,MAAM,CAAE,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAE,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAE,IAAI,CAAC,CAAC,GAAG,GAAG,CAAC,MAAM,GAAG,CAAC,CAAE,CAAA;IACvG,OAAO,MAAM,CAAC,MAAM,GAAG,EAAE,IAAI,WAAW,CAAC,CAAC,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,CAAA;AACtE,CAAC;AAOD;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CACxB,MAAc,EACd,IAAY,EACZ,UAAkE,EAAE;IAEpE,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAA;IACvC,IAAI,CAAC,IAAI,MAAM,CAAC,UAAU,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,SAAS,CAAA;IAErF,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC,EAAE,IAAI,IAAI,IAAI,EAAE,CAAC,CAAA;IAClD,KAAK,IAAI,MAAM,GAAG,CAAC,eAAe,EAAE,MAAM,IAAI,eAAe,EAAE,MAAM,IAAI,CAAC,EAAE,CAAC;QAC3E,MAAM,IAAI,GAAG,OAAO,GAAG,MAAM,CAAA;QAC7B,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS,IAAI,OAAO,CAAC,KAAK,KAAK,IAAI,IAAI,IAAI,IAAI,OAAO,CAAC,KAAK;YAAE,SAAQ;QAC5F,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAA;QACpD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAChC,2EAA2E;QAC3E,uCAAuC;QACvC,IAAI,QAAQ,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM,IAAI,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC;YAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC3F,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,OAI1B;IACC,MAAM,KAAK,GAAG,GAAG,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,kBAAkB,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAA;IAC5F,MAAM,KAAK,GAAG,IAAI,eAAe,CAAC;QAChC,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,SAAS,EAAE,MAAM;QACjB,MAAM,EAAE,MAAM,CAAC,WAAW,CAAC;QAC3B,MAAM,EAAE,MAAM,CAAC,mBAAmB,CAAC;KACpC,CAAC,CAAA;IACF,OAAO,kBAAkB,KAAK,IAAI,KAAK,CAAC,QAAQ,EAAE,EAAE,CAAA;AACtD,CAAC;AAED,gFAAgF;AAEhF;;;;;;;;;;GAUG;AACH,MAAM,MAAM,GAAG,IAAI,CAAA;AAEnB,MAAM,UAAU,cAAc,CAAC,MAAc,EAAE,GAAW;IACxD,IAAI,GAAG,CAAC,MAAM,KAAK,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAA;IAC/D,MAAM,KAAK,GAAG,WAAW,CAAC,EAAE,CAAC,CAAA;IAC7B,MAAM,MAAM,GAAG,cAAc,CAAC,aAAa,EAAE,GAAG,EAAE,KAAK,CAAC,CAAA;IACxD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAA;IAC3E,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC,UAAU,EAAE,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AAC/H,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,MAAc,EAAE,GAAW;IACxD,MAAM,CAAC,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IACrD,IAAI,OAAO,KAAK,MAAM,IAAI,KAAK,KAAK,SAAS,IAAI,IAAI,KAAK,SAAS,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;QACzF,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAA;IAC7C,CAAC;IACD,MAAM,QAAQ,GAAG,gBAAgB,CAAC,aAAa,EAAE,GAAG,EAAE,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,CAAA;IACtF,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC,CAAA;IAClD,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;AAC5G,CAAC;AAED,gFAAgF;AAEhF,+EAA+E;AAC/E,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAA;AAErC;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB;IAClC,MAAM,KAAK,GAAG,WAAW,CAAC,EAAE,CAAC,CAAA;IAC7B,MAAM,IAAI,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;IAC7C,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AAChG,CAAC;AAED,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,IAAY,EAAU,EAAE,CAC5D,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAA;AAE5C;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,qBAAqB,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC/E,CAAC"}
|