lambder 8.1.2 → 8.3.1
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 +136 -0
- package/README.md +8 -7
- package/dist/api/LambderApiGuards.d.ts +2 -17
- package/dist/api/LambderApiRateLimits.d.ts +2 -29
- package/dist/build/generatedTables.d.ts +72 -0
- package/dist/build/generatedTables.js +99 -0
- package/dist/build/writeApiGuardParams.d.ts +60 -0
- package/dist/build/writeApiGuardParams.js +85 -0
- package/dist/build/writeApiOptions.d.ts +68 -0
- package/dist/build/writeApiOptions.js +102 -0
- package/dist/build.d.ts +10 -4
- package/dist/build.js +7 -4
- package/dist/client/LambderUploadRunner.d.ts +7 -7
- package/dist/client/LambderUploadRunner.js +12 -21
- package/dist/client.d.ts +7 -0
- package/dist/client.js +11 -0
- package/dist/core/Lambder.d.ts +21 -0
- package/dist/core/Lambder.js +69 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/mock/LambderMockApp.d.ts +34 -17
- package/dist/mock/LambderMockApp.js +67 -21
- package/dist/mock/LambderMockCreateOptions.d.ts +68 -5
- package/dist/mock/LambderMockTypes.d.ts +29 -10
- package/dist/mock/lambderMockPoliciesFrom.d.ts +51 -0
- package/dist/mock/lambderMockPoliciesFrom.js +46 -0
- package/dist/mock.d.ts +3 -0
- package/dist/mock.js +3 -0
- package/dist/secrets/LambderOneShotSecrets.d.ts +166 -0
- package/dist/secrets/LambderOneShotSecrets.js +217 -0
- package/dist/session/LambderSessionCrypto.js +6 -16
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +3 -2
- package/dist/shared/contracts/LambderOneShotSecretStore.d.ts +122 -0
- package/dist/shared/contracts/LambderOneShotSecretStore.js +38 -0
- package/dist/shared/util/LambderBackoffTimer.d.ts +82 -0
- package/dist/shared/util/LambderBackoffTimer.js +86 -0
- package/dist/shared/util/LambderBase64.d.ts +14 -0
- package/dist/shared/util/LambderBase64.js +17 -0
- package/dist/shared/util/LambderSignedClaims.d.ts +78 -0
- package/dist/shared/util/LambderSignedClaims.js +109 -0
- package/dist/shared/util/LambderTextDigest.d.ts +19 -5
- package/dist/shared/util/LambderTextDigest.js +30 -5
- package/dist/shared/util/assertPlainData.d.ts +9 -0
- package/dist/shared/util/assertPlainData.js +41 -0
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
- package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
- package/dist/stores/LambderDdbOneShotSecretStore.d.ts +64 -0
- package/dist/stores/LambderDdbOneShotSecretStore.js +266 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +3 -2
- package/dist/stores/LambderMemoryIdempotencyStore.js +3 -2
- package/dist/stores/LambderMemoryOneShotSecretStore.d.ts +36 -0
- package/dist/stores/LambderMemoryOneShotSecretStore.js +93 -0
- package/dist/testing/LambderConformanceRunner.d.ts +46 -0
- package/dist/testing/LambderConformanceRunner.js +21 -0
- package/dist/testing/lambderIdempotencyStoreConformance.d.ts +33 -0
- package/dist/testing/lambderIdempotencyStoreConformance.js +237 -0
- package/dist/testing/lambderOneShotSecretStoreConformance.d.ts +43 -0
- package/dist/testing/lambderOneShotSecretStoreConformance.js +224 -0
- package/dist/testing/lambderRateLimiterConformance.d.ts +20 -0
- package/dist/testing/lambderRateLimiterConformance.js +72 -0
- package/dist/testing/lambderSessionStoreConformance.d.ts +27 -0
- package/dist/testing/lambderSessionStoreConformance.js +165 -0
- package/dist/testing.d.ts +14 -0
- package/dist/testing.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { keyedDigest, randomSecret } from "../shared/util/LambderSignedClaims.js";
|
|
2
|
+
import { constantTimeEquals } from "../shared/util/LambderTextDigest.js";
|
|
3
|
+
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
4
|
+
import { joinKeyFields } from "../shared/util/joinKeyFields.js";
|
|
5
|
+
const DIGITS = "0123456789";
|
|
6
|
+
/**
|
|
7
|
+
* How many secrets an issue draws before giving up on a digest another scope
|
|
8
|
+
* holds. A random token never meets one; a short code drawn from a small
|
|
9
|
+
* space with many out at once can, and five taken in a row means the space is
|
|
10
|
+
* too small for the codes out, which is a configuration to change rather than
|
|
11
|
+
* a draw to repeat.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_DRAWS = 5;
|
|
14
|
+
/**
|
|
15
|
+
* Codes and tokens an app hands out once and takes back once, over a store
|
|
16
|
+
* that settles their races.
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* const secrets = new LambderOneShotSecrets({
|
|
20
|
+
* store: new LambderDdbOneShotSecretStore({ tableName: "app-policies" }),
|
|
21
|
+
* secret: ONE_SHOT_SECRET,
|
|
22
|
+
* kinds: {
|
|
23
|
+
* emailCode: { shape: "code", length: 6, ttlSeconds: 600, maxAttempts: 5 },
|
|
24
|
+
* activationLink: { shape: "token", ttlSeconds: 48 * 3600 },
|
|
25
|
+
* },
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* const issued = await secrets.issue("emailCode", `register:${email}`, { cooldownSeconds: 30 });
|
|
29
|
+
* if(issued.issued) await sendEmail(email, issued.plaintext);
|
|
30
|
+
*
|
|
31
|
+
* const redeemed = await secrets.redeem("emailCode", `register:${email}`, typedCode);
|
|
32
|
+
* if(redeemed.state !== "accepted") refuse(...);
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* The store holds digests only, keyed under the app's secret with the kind
|
|
36
|
+
* and the scope folded in: a code issued to two scopes never collides, and a
|
|
37
|
+
* code cannot be replayed against another kind or scope. The plaintext leaves
|
|
38
|
+
* once, from `issue`; nothing here logs it or stores it.
|
|
39
|
+
*/
|
|
40
|
+
export class LambderOneShotSecrets {
|
|
41
|
+
store;
|
|
42
|
+
secret;
|
|
43
|
+
kinds;
|
|
44
|
+
now;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
if (typeof options.secret !== "string" || options.secret.length === 0)
|
|
47
|
+
throw new Error("Lambder: LambderOneShotSecrets needs a secret to key its digests with.");
|
|
48
|
+
const names = Object.keys(options.kinds);
|
|
49
|
+
if (names.length === 0)
|
|
50
|
+
throw new Error("Lambder: LambderOneShotSecrets was given no kinds; declare the kinds of secret the app hands out.");
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const kind = options.kinds[name];
|
|
53
|
+
assertPositiveInteger(kind.ttlSeconds, `kinds.${name}.ttlSeconds`);
|
|
54
|
+
if (kind.shape === "code") {
|
|
55
|
+
assertPositiveInteger(kind.length, `kinds.${name}.length`);
|
|
56
|
+
assertPositiveInteger(kind.maxAttempts, `kinds.${name}.maxAttempts`);
|
|
57
|
+
assertAlphabet(kind.alphabet ?? DIGITS, name);
|
|
58
|
+
}
|
|
59
|
+
else if (kind.shape === "token") {
|
|
60
|
+
if (kind.alphabet !== undefined || kind.length !== undefined) {
|
|
61
|
+
assertPositiveInteger(kind.length, `kinds.${name}.length`);
|
|
62
|
+
assertAlphabet(kind.alphabet ?? "", name);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
assertPositiveInteger(kind.bytes ?? 32, `kinds.${name}.bytes`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
throw new Error(`Lambder: kinds.${name}.shape must be "code" or "token", got ${JSON.stringify(kind.shape)}.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.store = options.store;
|
|
73
|
+
this.secret = options.secret;
|
|
74
|
+
this.kinds = options.kinds;
|
|
75
|
+
this.now = options.now ?? (() => Date.now());
|
|
76
|
+
}
|
|
77
|
+
nowSeconds() { return Math.floor(this.now() / 1000); }
|
|
78
|
+
kindOf(name) {
|
|
79
|
+
const kind = Object.prototype.hasOwnProperty.call(this.kinds, name) ? this.kinds[name] : undefined;
|
|
80
|
+
if (!kind)
|
|
81
|
+
throw new Error(`Lambder: LambderOneShotSecrets knows no kind "${name}".`);
|
|
82
|
+
return kind;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The digest a secret rests as. A code's carries its kind and scope, so
|
|
86
|
+
* the same code issued to two scopes never collides and a code cannot be
|
|
87
|
+
* replayed against another; a token's carries its kind, since a token is
|
|
88
|
+
* found by its digest alone, so two scopes can draw the same one, and the
|
|
89
|
+
* store's claim on the digest at issue is what keeps them apart. The
|
|
90
|
+
* fields are joined escaped, so no two distinct field lists produce one
|
|
91
|
+
* string.
|
|
92
|
+
*/
|
|
93
|
+
digestOf(kind, scope, plaintext) {
|
|
94
|
+
return keyedDigest(this.secret, scope === null ? joinKeyFields("token", kind, plaintext) : joinKeyFields("code", kind, scope, plaintext));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Mints one secret for the scope, retiring whatever the scope held, and
|
|
98
|
+
* answers the plaintext: the one time it exists outside the caller's
|
|
99
|
+
* hands. With `cooldownSeconds`, a scope whose current secret was issued
|
|
100
|
+
* less than that ago is refused instead, with the second it may ask
|
|
101
|
+
* again; of two callers racing past the cooldown, exactly one is issued.
|
|
102
|
+
* `meta` is what the app wants back at redemption: an identity, an
|
|
103
|
+
* issuing organization, as small strings.
|
|
104
|
+
*
|
|
105
|
+
* A secret whose digest another scope holds is drawn again, up to
|
|
106
|
+
* MAX_DRAWS times; past that the kind's alphabet and length leave too few
|
|
107
|
+
* secrets for the ones out at once, and the issue throws.
|
|
108
|
+
*/
|
|
109
|
+
async issue(kind, scope, options = {}) {
|
|
110
|
+
const definition = this.kindOf(kind);
|
|
111
|
+
const cooldown = options.cooldownSeconds === undefined ? undefined : assertPositiveInteger(options.cooldownSeconds, "cooldownSeconds");
|
|
112
|
+
const nowSeconds = this.nowSeconds();
|
|
113
|
+
for (let draw = 0; draw < MAX_DRAWS; draw += 1) {
|
|
114
|
+
const plaintext = definition.shape === "code" ? drawCode(definition.alphabet ?? DIGITS, definition.length)
|
|
115
|
+
: definition.alphabet !== undefined ? drawCode(definition.alphabet, definition.length)
|
|
116
|
+
: randomSecret(definition.bytes ?? 32);
|
|
117
|
+
const draft = {
|
|
118
|
+
kind,
|
|
119
|
+
scope,
|
|
120
|
+
shape: definition.shape,
|
|
121
|
+
digest: await this.digestOf(kind, definition.shape === "code" ? scope : null, plaintext),
|
|
122
|
+
issuedAt: nowSeconds,
|
|
123
|
+
expiresAt: nowSeconds + definition.ttlSeconds,
|
|
124
|
+
meta: { ...(options.meta ?? {}) },
|
|
125
|
+
};
|
|
126
|
+
const outcome = await this.store.issue(draft, { unlessIssuedAfter: cooldown === undefined ? undefined : nowSeconds - cooldown });
|
|
127
|
+
if (outcome.issued)
|
|
128
|
+
return { issued: true, plaintext, expiresAt: draft.expiresAt };
|
|
129
|
+
if (outcome.refused === "cooldown")
|
|
130
|
+
return { issued: false, refused: "cooldown", retryAt: outcome.issuedAt + (cooldown ?? 0) };
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`Lambder: kind "${kind}" drew ${MAX_DRAWS} secrets in a row whose digest another scope holds; its alphabet and length leave too few for the ones out at once.`);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Redeems a code for its scope. The try is counted before the code is
|
|
136
|
+
* looked at, in the write that reads the digest, so tries sent together
|
|
137
|
+
* are all counted; a right code past the ceiling is refused as exhausted.
|
|
138
|
+
* An accepted code is spent in the same call, exactly once.
|
|
139
|
+
*/
|
|
140
|
+
async redeem(kind, scope, candidate) {
|
|
141
|
+
const definition = this.kindOf(kind);
|
|
142
|
+
if (definition.shape !== "code")
|
|
143
|
+
throw new Error(`Lambder: kind "${kind}" is a token, redeemed by value with redeemToken().`);
|
|
144
|
+
const current = await this.store.findByScope(scope);
|
|
145
|
+
if (!current || current.kind !== kind)
|
|
146
|
+
return { state: "none" };
|
|
147
|
+
if (current.expiresAt <= this.nowSeconds())
|
|
148
|
+
return { state: "expired" };
|
|
149
|
+
if (current.attempts >= definition.maxAttempts)
|
|
150
|
+
return { state: "exhausted" };
|
|
151
|
+
const counted = await this.store.attempt(scope, current.id);
|
|
152
|
+
if (!counted)
|
|
153
|
+
return { state: "none" };
|
|
154
|
+
// Counted already, so this is the count with the try being made now.
|
|
155
|
+
if (counted.attempts > definition.maxAttempts)
|
|
156
|
+
return { state: "exhausted" };
|
|
157
|
+
if (!constantTimeEquals(counted.digest, await this.digestOf(kind, scope, candidate))) {
|
|
158
|
+
return { state: "wrong", attemptsLeft: Math.max(0, definition.maxAttempts - counted.attempts) };
|
|
159
|
+
}
|
|
160
|
+
return await this.accept(counted);
|
|
161
|
+
}
|
|
162
|
+
/** Redeems a token by its value: found by its digest, spent exactly once. A token of another kind, or none, is "none". */
|
|
163
|
+
async redeemToken(kind, candidate) {
|
|
164
|
+
const definition = this.kindOf(kind);
|
|
165
|
+
if (definition.shape !== "token")
|
|
166
|
+
throw new Error(`Lambder: kind "${kind}" is a code, redeemed with its scope through redeem().`);
|
|
167
|
+
// A token is 43 characters for 32 bytes; anything far past that is not one, and is not worth a digest.
|
|
168
|
+
if (candidate.length === 0 || candidate.length > 512)
|
|
169
|
+
return { state: "none" };
|
|
170
|
+
const current = await this.store.findByDigest(await this.digestOf(kind, null, candidate));
|
|
171
|
+
if (!current || current.kind !== kind)
|
|
172
|
+
return { state: "none" };
|
|
173
|
+
if (current.expiresAt <= this.nowSeconds())
|
|
174
|
+
return { state: "expired" };
|
|
175
|
+
return await this.accept(current);
|
|
176
|
+
}
|
|
177
|
+
/** Ends whatever the scope holds: after the thing it proved is settled another way, or when what was sent never arrived. */
|
|
178
|
+
async retire(scope) {
|
|
179
|
+
await this.store.retire(scope);
|
|
180
|
+
}
|
|
181
|
+
/** Spends the record; a redemption racing this one and winning makes it "none". */
|
|
182
|
+
async accept(record) {
|
|
183
|
+
if (!(await this.store.consume(record.scope, record.id)))
|
|
184
|
+
return { state: "none" };
|
|
185
|
+
return { state: "accepted", scope: record.scope, meta: { ...record.meta }, issuedAt: record.issuedAt };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const assertAlphabet = (alphabet, name) => {
|
|
189
|
+
if (alphabet.length < 2 || alphabet.length > 256 || new Set(alphabet).size !== alphabet.length) {
|
|
190
|
+
throw new Error(`Lambder: kinds.${name}.alphabet must be 2 to 256 distinct characters.`);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* A code of `length` characters drawn uniformly from `alphabet`. Bytes at or
|
|
195
|
+
* above the largest multiple of the alphabet's size are discarded, so the
|
|
196
|
+
* modulo cannot favour the alphabet's first characters.
|
|
197
|
+
*/
|
|
198
|
+
const drawCode = (alphabet, length) => {
|
|
199
|
+
const webCrypto = globalThis.crypto;
|
|
200
|
+
if (typeof webCrypto?.getRandomValues !== "function") {
|
|
201
|
+
throw new Error("Lambder needs crypto.getRandomValues in this runtime to draw a code. Every browser provides it; Node 20+ provides it as globalThis.crypto.");
|
|
202
|
+
}
|
|
203
|
+
const ceiling = Math.floor(256 / alphabet.length) * alphabet.length;
|
|
204
|
+
const characters = [];
|
|
205
|
+
const buffer = new Uint8Array(length * 2);
|
|
206
|
+
while (characters.length < length) {
|
|
207
|
+
webCrypto.getRandomValues(buffer);
|
|
208
|
+
for (const byte of buffer) {
|
|
209
|
+
if (byte >= ceiling)
|
|
210
|
+
continue;
|
|
211
|
+
characters.push(alphabet[byte % alphabet.length]);
|
|
212
|
+
if (characters.length === length)
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return characters.join("");
|
|
217
|
+
};
|
|
@@ -11,16 +11,7 @@
|
|
|
11
11
|
* not a table anybody can leak, so hashing there protects nothing.
|
|
12
12
|
*/
|
|
13
13
|
import { getCrypto } from "../shared/util/LambderNodeModules.js";
|
|
14
|
-
import { bytesToHexString, resolveWebCrypto, sha256HexOf } from "../shared/util/LambderTextDigest.js";
|
|
15
|
-
/** Length-aware, timing-neutral string comparison: no early exit on the first differing character. */
|
|
16
|
-
const constantTimeEqual = (a, b) => {
|
|
17
|
-
if (a.length !== b.length)
|
|
18
|
-
return false;
|
|
19
|
-
let difference = 0;
|
|
20
|
-
for (let i = 0; i < a.length; i += 1)
|
|
21
|
-
difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
22
|
-
return difference === 0;
|
|
23
|
-
};
|
|
14
|
+
import { bytesToHexString, constantTimeEquals, hmacSha256Of, resolveWebCrypto, sha256HexOf } from "../shared/util/LambderTextDigest.js";
|
|
24
15
|
/** True when this runtime offers WebCrypto's subtle API (secure contexts in browsers; Node 20+). */
|
|
25
16
|
export const isWebCryptoAvailable = () => typeof globalThis.crypto?.subtle?.digest === "function" && typeof globalThis.crypto.getRandomValues === "function";
|
|
26
17
|
/** sha256 and HMAC through crypto.subtle and randomness through getRandomValues: the default. */
|
|
@@ -62,10 +53,9 @@ export class LambderWebCrypto {
|
|
|
62
53
|
return await sha256HexOf(value);
|
|
63
54
|
}
|
|
64
55
|
async hmacSha256Hex(key, value) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return bytesToHexString(new Uint8Array(await webCrypto.subtle.sign("HMAC", hmacKey, encoder.encode(value))));
|
|
56
|
+
// ready() first, as for sha256Hex; the HMAC is the one every layer shares.
|
|
57
|
+
await this.ready();
|
|
58
|
+
return bytesToHexString(await hmacSha256Of(key, value));
|
|
69
59
|
}
|
|
70
60
|
async randomHex(bytes) {
|
|
71
61
|
const webCrypto = await this.ready();
|
|
@@ -82,7 +72,7 @@ export class LambderWebCrypto {
|
|
|
82
72
|
if (left.length === right.length)
|
|
83
73
|
return nodeCrypto.timingSafeEqual(left, right);
|
|
84
74
|
}
|
|
85
|
-
return
|
|
75
|
+
return constantTimeEquals(a, b);
|
|
86
76
|
}
|
|
87
77
|
}
|
|
88
78
|
/**
|
|
@@ -110,6 +100,6 @@ export class LambderPlainSessionCrypto {
|
|
|
110
100
|
return bytesToHexString(random);
|
|
111
101
|
}
|
|
112
102
|
constantTimeEqual(a, b) {
|
|
113
|
-
return
|
|
103
|
+
return constantTimeEquals(a, b);
|
|
114
104
|
}
|
|
115
105
|
}
|
|
@@ -38,8 +38,9 @@ export type LambderIdempotencyBeginResult = {
|
|
|
38
38
|
* atomically, settled by the claim's owner. LambderDdbIdempotencyStore and
|
|
39
39
|
* LambderMemoryIdempotencyStore implement it; an app may bring its own.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* The conformance suite `lambder/testing` exports
|
|
42
|
+
* (lambderIdempotencyStoreConformance) asserts the rules, against these two
|
|
43
|
+
* and against an app's own. Four are easy to get wrong:
|
|
43
44
|
*
|
|
44
45
|
* A read hands back a COPY of the record, never the stored object, because a
|
|
45
46
|
* caller applies its own headers onto what it gets back.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-shot secret vocabulary: what a store holds of a secret, and the six
|
|
3
|
+
* methods LambderOneShotSecrets asks of it. Kept apart from the class, like
|
|
4
|
+
* every store interface here: a store implements this and nothing else, and
|
|
5
|
+
* depends on nothing.
|
|
6
|
+
*
|
|
7
|
+
* A record is the digest of a secret and the facts around it, never the
|
|
8
|
+
* secret itself. One record is live per scope: issuing a new one retires
|
|
9
|
+
* whatever the scope held, in the same act. A record stops being live when it
|
|
10
|
+
* is consumed, replaced, or retired; an expired record is still handed back,
|
|
11
|
+
* because the class answers "expired" from it rather than "none", for as long
|
|
12
|
+
* as the store's own housekeeping keeps it.
|
|
13
|
+
*
|
|
14
|
+
* Every race a one-shot secret meets is settled here, once, and the
|
|
15
|
+
* conformance suite `lambder/testing` exports
|
|
16
|
+
* (lambderOneShotSecretStoreConformance) asserts each, against Lambder's
|
|
17
|
+
* stores and against an app's own:
|
|
18
|
+
*
|
|
19
|
+
* - `issue` writes the new record and retires the old in one act, and a
|
|
20
|
+
* cooldown is a condition on that same write, so of two callers asking at
|
|
21
|
+
* once exactly one is answered with a secret and the other with the moment
|
|
22
|
+
* it may ask again.
|
|
23
|
+
* - `issue` claims a token's digest in that same act. A token is found by its
|
|
24
|
+
* digest alone, so two scopes that drew the same token (a short code typed
|
|
25
|
+
* by hand, with many out at once) would otherwise share one digest, and the
|
|
26
|
+
* holder of one would redeem the other's. The claim is refused while
|
|
27
|
+
* another scope's record holds the digest, and the class draws again.
|
|
28
|
+
* - `attempt` counts the try in the same act that reads the digest, so tries
|
|
29
|
+
* sent together are all counted; counted afterwards, they would all read
|
|
30
|
+
* the same count and a ceiling of five would be as many as a caller cared
|
|
31
|
+
* to send at once.
|
|
32
|
+
* - `consume` is conditional on the record still being the one the caller
|
|
33
|
+
* read, so of two redemptions of one secret exactly one is accepted.
|
|
34
|
+
*
|
|
35
|
+
* `attempt` and `consume` name a record by its scope and its id together,
|
|
36
|
+
* and a record of another scope is not the one named, whatever its id.
|
|
37
|
+
*/
|
|
38
|
+
/** What a store holds of one secret: its digest and the facts around it, never the secret. */
|
|
39
|
+
export type LambderOneShotSecretRecord = {
|
|
40
|
+
/** The store's own identity for this record, what attempt() and consume() name so a record replaced meanwhile is not the one acted on. */
|
|
41
|
+
id: string;
|
|
42
|
+
/** Which kind of secret, in the app's vocabulary. */
|
|
43
|
+
kind: string;
|
|
44
|
+
/** What the secret proves, in the app's words: an address for a purpose, a recipient, a device. */
|
|
45
|
+
scope: string;
|
|
46
|
+
/** The keyed digest of the secret, as the class computes it. */
|
|
47
|
+
digest: string;
|
|
48
|
+
/** Epoch seconds. */
|
|
49
|
+
issuedAt: number;
|
|
50
|
+
/** Epoch seconds; the store's TTL where it has one. */
|
|
51
|
+
expiresAt: number;
|
|
52
|
+
/** Tries made against the record so far. */
|
|
53
|
+
attempts: number;
|
|
54
|
+
/** What the app asked to have back at redemption: small strings only. */
|
|
55
|
+
meta: Record<string, string>;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* How a secret is redeemed, which is what a store needs to know of it at
|
|
59
|
+
* issue: a code with its scope (its digest carries the scope, so no other
|
|
60
|
+
* scope can hold it), a token by its value alone (so its digest is claimed).
|
|
61
|
+
*/
|
|
62
|
+
export type LambderOneShotSecretShape = "code" | "token";
|
|
63
|
+
/** A record as the class writes it: everything but what the store assigns, and how it is redeemed. */
|
|
64
|
+
export type LambderOneShotSecretDraft = Omit<LambderOneShotSecretRecord, "id" | "attempts"> & {
|
|
65
|
+
shape: LambderOneShotSecretShape;
|
|
66
|
+
};
|
|
67
|
+
export type LambderOneShotIssueOutcome = {
|
|
68
|
+
issued: true;
|
|
69
|
+
id: string;
|
|
70
|
+
}
|
|
71
|
+
/** Refused by the cooldown: the scope's record was issued after the second named, at `issuedAt`. Nothing was written. */
|
|
72
|
+
| {
|
|
73
|
+
issued: false;
|
|
74
|
+
refused: "cooldown";
|
|
75
|
+
issuedAt: number;
|
|
76
|
+
}
|
|
77
|
+
/** Refused because another scope's record holds this digest. Nothing was written; the class draws another secret. */
|
|
78
|
+
| {
|
|
79
|
+
issued: false;
|
|
80
|
+
refused: "digestTaken";
|
|
81
|
+
};
|
|
82
|
+
export interface LambderOneShotSecretStore {
|
|
83
|
+
/**
|
|
84
|
+
* Stores `draft` as the scope's one live record, retiring whatever the
|
|
85
|
+
* scope held, in one act. With `unlessIssuedAfter` (epoch seconds), the
|
|
86
|
+
* write is refused when the scope's current record was issued after that
|
|
87
|
+
* second, and the refusal carries when it was issued; of two callers
|
|
88
|
+
* racing past a cooldown, exactly one is issued.
|
|
89
|
+
*
|
|
90
|
+
* For a token, the same act claims the digest: refused as `digestTaken`,
|
|
91
|
+
* writing nothing, while a record of another scope holds it, so of two
|
|
92
|
+
* scopes racing for one digest exactly one is issued. A store may be
|
|
93
|
+
* stricter than that and refuse a digest no live record holds (one it
|
|
94
|
+
* has not cleaned up yet, or one its history of spent secrets already
|
|
95
|
+
* has, a code's included); the class draws again either way.
|
|
96
|
+
*/
|
|
97
|
+
issue(draft: LambderOneShotSecretDraft, options: {
|
|
98
|
+
unlessIssuedAfter?: number;
|
|
99
|
+
}): Promise<LambderOneShotIssueOutcome>;
|
|
100
|
+
/** The scope's current record, expired or not, or null once it is consumed, retired, or gone. */
|
|
101
|
+
findByScope(scope: string): Promise<LambderOneShotSecretRecord | null>;
|
|
102
|
+
/**
|
|
103
|
+
* The current record holding this digest, or null: a digest of a record
|
|
104
|
+
* that was replaced finds nothing. Asked only for a token; a store need
|
|
105
|
+
* not find a code by its digest.
|
|
106
|
+
*/
|
|
107
|
+
findByDigest(digest: string): Promise<LambderOneShotSecretRecord | null>;
|
|
108
|
+
/**
|
|
109
|
+
* Counts one try against the record, in the act that reads it: the
|
|
110
|
+
* record with the try already counted, or null when the scope's current
|
|
111
|
+
* record is no longer the one named (consumed, replaced, retired).
|
|
112
|
+
*
|
|
113
|
+
* Only a code is tried against its scope; a token is redeemed by value.
|
|
114
|
+
* A store that holds token kinds alone is never asked, and may keep no
|
|
115
|
+
* count of tries at all.
|
|
116
|
+
*/
|
|
117
|
+
attempt(scope: string, id: string): Promise<LambderOneShotSecretRecord | null>;
|
|
118
|
+
/** Ends the record named, so it is found no more; false when it is no longer the scope's current record, another consume included. */
|
|
119
|
+
consume(scope: string, id: string): Promise<boolean>;
|
|
120
|
+
/** Ends the scope's current record, whatever it is. */
|
|
121
|
+
retire(scope: string): Promise<void>;
|
|
122
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-shot secret vocabulary: what a store holds of a secret, and the six
|
|
3
|
+
* methods LambderOneShotSecrets asks of it. Kept apart from the class, like
|
|
4
|
+
* every store interface here: a store implements this and nothing else, and
|
|
5
|
+
* depends on nothing.
|
|
6
|
+
*
|
|
7
|
+
* A record is the digest of a secret and the facts around it, never the
|
|
8
|
+
* secret itself. One record is live per scope: issuing a new one retires
|
|
9
|
+
* whatever the scope held, in the same act. A record stops being live when it
|
|
10
|
+
* is consumed, replaced, or retired; an expired record is still handed back,
|
|
11
|
+
* because the class answers "expired" from it rather than "none", for as long
|
|
12
|
+
* as the store's own housekeeping keeps it.
|
|
13
|
+
*
|
|
14
|
+
* Every race a one-shot secret meets is settled here, once, and the
|
|
15
|
+
* conformance suite `lambder/testing` exports
|
|
16
|
+
* (lambderOneShotSecretStoreConformance) asserts each, against Lambder's
|
|
17
|
+
* stores and against an app's own:
|
|
18
|
+
*
|
|
19
|
+
* - `issue` writes the new record and retires the old in one act, and a
|
|
20
|
+
* cooldown is a condition on that same write, so of two callers asking at
|
|
21
|
+
* once exactly one is answered with a secret and the other with the moment
|
|
22
|
+
* it may ask again.
|
|
23
|
+
* - `issue` claims a token's digest in that same act. A token is found by its
|
|
24
|
+
* digest alone, so two scopes that drew the same token (a short code typed
|
|
25
|
+
* by hand, with many out at once) would otherwise share one digest, and the
|
|
26
|
+
* holder of one would redeem the other's. The claim is refused while
|
|
27
|
+
* another scope's record holds the digest, and the class draws again.
|
|
28
|
+
* - `attempt` counts the try in the same act that reads the digest, so tries
|
|
29
|
+
* sent together are all counted; counted afterwards, they would all read
|
|
30
|
+
* the same count and a ceiling of five would be as many as a caller cared
|
|
31
|
+
* to send at once.
|
|
32
|
+
* - `consume` is conditional on the record still being the one the caller
|
|
33
|
+
* read, so of two redemptions of one secret exactly one is accepted.
|
|
34
|
+
*
|
|
35
|
+
* `attempt` and `consume` name a record by its scope and its id together,
|
|
36
|
+
* and a record of another scope is not the one named, whatever its id.
|
|
37
|
+
*/
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One pending wait at a time, where each retry after a failure waits longer
|
|
3
|
+
* than the one before it.
|
|
4
|
+
*
|
|
5
|
+
* Most things that retry also wait for other reasons (a refresh cadence, a
|
|
6
|
+
* pause before recreating something), and those waits must never stack with a
|
|
7
|
+
* retry. So the timer holds exactly one wait of either kind: `retry` and
|
|
8
|
+
* `wait` climb the ladder, `after` waits a fixed time without climbing it, and
|
|
9
|
+
* scheduling any of them replaces whatever was waiting. A caller says what to
|
|
10
|
+
* run and when it worked, and never keeps a handle and a counter of its own.
|
|
11
|
+
*
|
|
12
|
+
* The ladder: a retry waits the base plus a share of a ceiling that grows by
|
|
13
|
+
* `factor` with every failed attempt, the whole never past `maxMs`. With full
|
|
14
|
+
* jitter (the default) the share is random, so anything many clients fail at
|
|
15
|
+
* together (a deploy dropping every socket, a power cut bringing every screen
|
|
16
|
+
* in a building up at once) is retried across the whole window instead of in
|
|
17
|
+
* step, which is what keeps the herd off the server.
|
|
18
|
+
*
|
|
19
|
+
* Runs wherever setTimeout does: a browser, a Worker, Node. The upload runner
|
|
20
|
+
* waits on one between tries at storage, and an app's reconnecting client or
|
|
21
|
+
* self-healing screen holds one of its own.
|
|
22
|
+
*/
|
|
23
|
+
export type LambderBackoffTimerOptions = {
|
|
24
|
+
/**
|
|
25
|
+
* The shortest retry wait, in milliseconds. The first after a reset falls
|
|
26
|
+
* between it and twice it (exactly twice with `jitter: "none"`), so even
|
|
27
|
+
* the first retries of many clients spread out. Default: 1000.
|
|
28
|
+
*/
|
|
29
|
+
baseMs?: number;
|
|
30
|
+
/**
|
|
31
|
+
* The longest any retry wait is, in milliseconds, however many attempts
|
|
32
|
+
* have failed; at least `baseMs`. Default: 60000, or `baseMs` when that is
|
|
33
|
+
* longer.
|
|
34
|
+
*/
|
|
35
|
+
maxMs?: number;
|
|
36
|
+
/** How much the ceiling grows with each failed attempt. Default: 2. */
|
|
37
|
+
factor?: number;
|
|
38
|
+
/**
|
|
39
|
+
* "full" (the default): the base plus a random share of the ceiling, which
|
|
40
|
+
* spreads a herd out. "none": the base plus the whole ceiling, a
|
|
41
|
+
* predictable ladder for a caller that is alone.
|
|
42
|
+
*/
|
|
43
|
+
jitter?: "full" | "none";
|
|
44
|
+
};
|
|
45
|
+
export declare class LambderBackoffTimer {
|
|
46
|
+
private readonly baseMs;
|
|
47
|
+
private readonly maxMs;
|
|
48
|
+
private readonly factor;
|
|
49
|
+
private readonly jitter;
|
|
50
|
+
private attempts;
|
|
51
|
+
private timer;
|
|
52
|
+
/** Settles the promise of a `wait` that cancel() or a replacement drops, so no `await` is left hanging. */
|
|
53
|
+
private dropPending;
|
|
54
|
+
constructor(options?: LambderBackoffTimerOptions);
|
|
55
|
+
/** True while a wait of any kind is pending. False again by the time it runs. */
|
|
56
|
+
get pending(): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Runs `run` after the next rung of the ladder, counting one more failed
|
|
59
|
+
* attempt. Replaces whatever was waiting.
|
|
60
|
+
*/
|
|
61
|
+
retry(run: () => void): void;
|
|
62
|
+
/**
|
|
63
|
+
* Resolves after the next rung of the ladder, counting one more failed
|
|
64
|
+
* attempt: the `retry` for code that awaits rather than calls back.
|
|
65
|
+
* Replaces whatever was waiting. Rejects at once with the signal's reason
|
|
66
|
+
* when `signal` aborts, and with an Error when cancel() or a later wait
|
|
67
|
+
* drops it before it ran, so an await on it always settles.
|
|
68
|
+
*/
|
|
69
|
+
wait(signal?: AbortSignal): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Runs `run` after a fixed wait, off the ladder: nothing failed, so nothing
|
|
72
|
+
* climbs. Replaces whatever was waiting.
|
|
73
|
+
*/
|
|
74
|
+
after(delayMs: number, run: () => void): void;
|
|
75
|
+
/** The attempt worked: the next failure waits the shortest time again. A pending wait is left alone. */
|
|
76
|
+
reset(): void;
|
|
77
|
+
/** Drops the pending wait (the caller is trying right now, or going away), keeping the count. */
|
|
78
|
+
cancel(): void;
|
|
79
|
+
/** The next wait on the ladder, in milliseconds, counting one more failed attempt. */
|
|
80
|
+
private nextRung;
|
|
81
|
+
private schedule;
|
|
82
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { assertNumberAtLeast } from "./LambderOptionChecks.js";
|
|
2
|
+
export class LambderBackoffTimer {
|
|
3
|
+
baseMs;
|
|
4
|
+
maxMs;
|
|
5
|
+
factor;
|
|
6
|
+
jitter;
|
|
7
|
+
attempts = 0;
|
|
8
|
+
timer = null;
|
|
9
|
+
/** Settles the promise of a `wait` that cancel() or a replacement drops, so no `await` is left hanging. */
|
|
10
|
+
dropPending = null;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.baseMs = assertNumberAtLeast(options.baseMs ?? 1_000, 0, "baseMs");
|
|
13
|
+
this.maxMs = assertNumberAtLeast(options.maxMs ?? Math.max(60_000, this.baseMs), this.baseMs, "maxMs");
|
|
14
|
+
this.factor = assertNumberAtLeast(options.factor ?? 2, 1, "factor");
|
|
15
|
+
this.jitter = options.jitter ?? "full";
|
|
16
|
+
}
|
|
17
|
+
/** True while a wait of any kind is pending. False again by the time it runs. */
|
|
18
|
+
get pending() {
|
|
19
|
+
return this.timer !== null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Runs `run` after the next rung of the ladder, counting one more failed
|
|
23
|
+
* attempt. Replaces whatever was waiting.
|
|
24
|
+
*/
|
|
25
|
+
retry(run) {
|
|
26
|
+
this.schedule(this.nextRung(), run, null);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolves after the next rung of the ladder, counting one more failed
|
|
30
|
+
* attempt: the `retry` for code that awaits rather than calls back.
|
|
31
|
+
* Replaces whatever was waiting. Rejects at once with the signal's reason
|
|
32
|
+
* when `signal` aborts, and with an Error when cancel() or a later wait
|
|
33
|
+
* drops it before it ran, so an await on it always settles.
|
|
34
|
+
*/
|
|
35
|
+
wait(signal) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
if (signal?.aborted)
|
|
38
|
+
return reject(abortReasonOf(signal));
|
|
39
|
+
const onAbort = () => this.cancel();
|
|
40
|
+
const settle = (outcome) => {
|
|
41
|
+
signal?.removeEventListener("abort", onAbort);
|
|
42
|
+
outcome();
|
|
43
|
+
};
|
|
44
|
+
this.schedule(this.nextRung(), () => settle(resolve), () => settle(() => reject(signal?.aborted ? abortReasonOf(signal) : new Error("LambderBackoffTimer: the wait was dropped by cancel() or by a later wait before it ran."))));
|
|
45
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Runs `run` after a fixed wait, off the ladder: nothing failed, so nothing
|
|
50
|
+
* climbs. Replaces whatever was waiting.
|
|
51
|
+
*/
|
|
52
|
+
after(delayMs, run) {
|
|
53
|
+
this.schedule(delayMs, run, null);
|
|
54
|
+
}
|
|
55
|
+
/** The attempt worked: the next failure waits the shortest time again. A pending wait is left alone. */
|
|
56
|
+
reset() {
|
|
57
|
+
this.attempts = 0;
|
|
58
|
+
}
|
|
59
|
+
/** Drops the pending wait (the caller is trying right now, or going away), keeping the count. */
|
|
60
|
+
cancel() {
|
|
61
|
+
if (this.timer === null)
|
|
62
|
+
return;
|
|
63
|
+
clearTimeout(this.timer);
|
|
64
|
+
this.timer = null;
|
|
65
|
+
const drop = this.dropPending;
|
|
66
|
+
this.dropPending = null;
|
|
67
|
+
drop?.();
|
|
68
|
+
}
|
|
69
|
+
/** The next wait on the ladder, in milliseconds, counting one more failed attempt. */
|
|
70
|
+
nextRung() {
|
|
71
|
+
const ceiling = Math.min(this.baseMs * this.factor ** this.attempts, this.maxMs - this.baseMs);
|
|
72
|
+
this.attempts += 1;
|
|
73
|
+
return this.baseMs + (this.jitter === "full" ? Math.random() : 1) * ceiling;
|
|
74
|
+
}
|
|
75
|
+
schedule(delayMs, run, drop) {
|
|
76
|
+
this.cancel();
|
|
77
|
+
this.dropPending = drop;
|
|
78
|
+
this.timer = setTimeout(() => {
|
|
79
|
+
this.timer = null;
|
|
80
|
+
this.dropPending = null;
|
|
81
|
+
run();
|
|
82
|
+
}, delayMs);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** What an aborted signal carries, or an Error for a runtime whose signals carry nothing. */
|
|
86
|
+
const abortReasonOf = (signal) => signal.reason ?? new Error("LambderBackoffTimer: the wait was aborted.");
|
|
@@ -8,3 +8,17 @@ export declare const bytesToBase64: (bytes: Uint8Array) => string;
|
|
|
8
8
|
export declare const base64ToBytes: (base64: string) => Uint8Array;
|
|
9
9
|
/** The base64 of UTF-8 text back to the text. */
|
|
10
10
|
export declare const base64ToText: (base64: string) => string;
|
|
11
|
+
/**
|
|
12
|
+
* The base64url alphabet (RFC 4648 section 5) without padding: what a token
|
|
13
|
+
* or a digest carries where "+", "/" and "=" would need escaping, in a URL, a
|
|
14
|
+
* header or a database column.
|
|
15
|
+
*/
|
|
16
|
+
export declare const bytesToBase64Url: (bytes: Uint8Array) => string;
|
|
17
|
+
/**
|
|
18
|
+
* Whether `text` is base64url and nothing else, so decoding it decodes rather
|
|
19
|
+
* than guesses; Buffer decodes anything. A length that leaves a remainder of
|
|
20
|
+
* one past a multiple of four is no encoding of any bytes, and the platform's
|
|
21
|
+
* atob throws on it where Buffer shrugs, so it is refused here on both.
|
|
22
|
+
*/
|
|
23
|
+
export declare const isBase64Url: (text: string) => boolean;
|
|
24
|
+
export declare const base64UrlToBytes: (base64Url: string) => Uint8Array;
|
|
@@ -25,3 +25,20 @@ export const base64ToBytes = (base64) => {
|
|
|
25
25
|
};
|
|
26
26
|
/** The base64 of UTF-8 text back to the text. */
|
|
27
27
|
export const base64ToText = (base64) => new TextDecoder().decode(base64ToBytes(base64));
|
|
28
|
+
/**
|
|
29
|
+
* The base64url alphabet (RFC 4648 section 5) without padding: what a token
|
|
30
|
+
* or a digest carries where "+", "/" and "=" would need escaping, in a URL, a
|
|
31
|
+
* header or a database column.
|
|
32
|
+
*/
|
|
33
|
+
export const bytesToBase64Url = (bytes) => bytesToBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
+
/**
|
|
35
|
+
* Whether `text` is base64url and nothing else, so decoding it decodes rather
|
|
36
|
+
* than guesses; Buffer decodes anything. A length that leaves a remainder of
|
|
37
|
+
* one past a multiple of four is no encoding of any bytes, and the platform's
|
|
38
|
+
* atob throws on it where Buffer shrugs, so it is refused here on both.
|
|
39
|
+
*/
|
|
40
|
+
export const isBase64Url = (text) => text.length % 4 !== 1 && /^[A-Za-z0-9_-]*$/.test(text);
|
|
41
|
+
export const base64UrlToBytes = (base64Url) => {
|
|
42
|
+
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
43
|
+
return base64ToBytes(base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "="));
|
|
44
|
+
};
|