lambder 8.1.2 → 9.0.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +23 -31
  3. package/dist/api/LambderApiCallContext.d.ts +31 -1
  4. package/dist/api/LambderApiCallContext.js +8 -0
  5. package/dist/api/LambderApiDefinition.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.d.ts +1 -1
  7. package/dist/api/LambderApiEnvelope.js +3 -4
  8. package/dist/api/LambderApiGuards.d.ts +2 -17
  9. package/dist/api/LambderApiIdempotency.js +5 -7
  10. package/dist/api/LambderApiRateLimits.d.ts +2 -29
  11. package/dist/build/generatedTables.d.ts +72 -0
  12. package/dist/build/generatedTables.js +99 -0
  13. package/dist/build/writeApiGuardParams.d.ts +60 -0
  14. package/dist/build/writeApiGuardParams.js +85 -0
  15. package/dist/build/writeApiOptions.d.ts +68 -0
  16. package/dist/build/writeApiOptions.js +102 -0
  17. package/dist/build.d.ts +10 -4
  18. package/dist/build.js +7 -4
  19. package/dist/client/LambderCaller.d.ts +0 -4
  20. package/dist/client/LambderCaller.js +1 -9
  21. package/dist/client/LambderUploadRunner.d.ts +7 -7
  22. package/dist/client/LambderUploadRunner.js +12 -21
  23. package/dist/client.d.ts +7 -0
  24. package/dist/client.js +11 -0
  25. package/dist/core/Lambder.d.ts +71 -12
  26. package/dist/core/Lambder.js +116 -38
  27. package/dist/core/LambderContext.d.ts +9 -6
  28. package/dist/core/LambderContext.js +2 -1
  29. package/dist/core/LambderResolver.d.ts +6 -12
  30. package/dist/core/LambderResolver.js +2 -14
  31. package/dist/core/LambderResponseBuilder.d.ts +15 -70
  32. package/dist/core/LambderResponseBuilder.js +15 -99
  33. package/dist/index.d.ts +16 -3
  34. package/dist/index.js +14 -1
  35. package/dist/invoke/LambderInvokeCaller.js +3 -4
  36. package/dist/mock/LambderMockApp.d.ts +34 -17
  37. package/dist/mock/LambderMockApp.js +69 -24
  38. package/dist/mock/LambderMockCreateOptions.d.ts +70 -7
  39. package/dist/mock/LambderMockTypes.d.ts +31 -21
  40. package/dist/mock/lambderMockPoliciesFrom.d.ts +51 -0
  41. package/dist/mock/lambderMockPoliciesFrom.js +46 -0
  42. package/dist/mock.d.ts +3 -0
  43. package/dist/mock.js +3 -0
  44. package/dist/secrets/LambderOneShotSecrets.d.ts +166 -0
  45. package/dist/secrets/LambderOneShotSecrets.js +217 -0
  46. package/dist/session/LambderSessionCrypto.js +6 -16
  47. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +3 -2
  48. package/dist/shared/contracts/LambderOneShotSecretStore.d.ts +122 -0
  49. package/dist/shared/contracts/LambderOneShotSecretStore.js +38 -0
  50. package/dist/shared/util/LambderBackoffTimer.d.ts +82 -0
  51. package/dist/shared/util/LambderBackoffTimer.js +86 -0
  52. package/dist/shared/util/LambderBase64.d.ts +14 -0
  53. package/dist/shared/util/LambderBase64.js +17 -0
  54. package/dist/shared/util/LambderSignedClaims.d.ts +78 -0
  55. package/dist/shared/util/LambderSignedClaims.js +109 -0
  56. package/dist/shared/util/LambderTextDigest.d.ts +19 -5
  57. package/dist/shared/util/LambderTextDigest.js +30 -5
  58. package/dist/shared/util/LambderTypeUtilities.d.ts +18 -0
  59. package/dist/shared/util/assertPlainData.d.ts +9 -0
  60. package/dist/shared/util/assertPlainData.js +41 -0
  61. package/dist/shared/wire/LambderAnswerHeaders.d.ts +3 -2
  62. package/dist/shared/wire/LambderAnswerHeaders.js +3 -2
  63. package/dist/shared/wire/LambderApiContract.d.ts +9 -14
  64. package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
  65. package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
  66. package/dist/shared/wire/LambderApiRefusal.d.ts +3 -4
  67. package/dist/shared/wire/LambderApiRefusal.js +3 -4
  68. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +64 -0
  69. package/dist/stores/LambderDdbOneShotSecretStore.js +266 -0
  70. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +3 -2
  71. package/dist/stores/LambderMemoryIdempotencyStore.js +3 -2
  72. package/dist/stores/LambderMemoryOneShotSecretStore.d.ts +36 -0
  73. package/dist/stores/LambderMemoryOneShotSecretStore.js +93 -0
  74. package/dist/testing/LambderConformanceRunner.d.ts +46 -0
  75. package/dist/testing/LambderConformanceRunner.js +21 -0
  76. package/dist/testing/lambderIdempotencyStoreConformance.d.ts +33 -0
  77. package/dist/testing/lambderIdempotencyStoreConformance.js +237 -0
  78. package/dist/testing/lambderOneShotSecretStoreConformance.d.ts +43 -0
  79. package/dist/testing/lambderOneShotSecretStoreConformance.js +224 -0
  80. package/dist/testing/lambderRateLimiterConformance.d.ts +20 -0
  81. package/dist/testing/lambderRateLimiterConformance.js +72 -0
  82. package/dist/testing/lambderSessionStoreConformance.d.ts +27 -0
  83. package/dist/testing/lambderSessionStoreConformance.js +165 -0
  84. package/dist/testing.d.ts +14 -0
  85. package/dist/testing.js +12 -0
  86. package/package.json +1 -1
@@ -0,0 +1,166 @@
1
+ import type { LambderOneShotSecretStore } from "../shared/contracts/LambderOneShotSecretStore.js";
2
+ /**
3
+ * The shapes a kind of secret takes, told apart by how they are redeemed. A
4
+ * code is bound to a scope the app names (an address for a purpose, a
5
+ * recipient, a device), redeemed with that scope, and defended by a ceiling on
6
+ * tries, which is what lets it be short. A token carries its own identity, is
7
+ * redeemed by value alone, and has no ceiling: random bytes, long enough that
8
+ * guessing is not a thing, or, for a code somebody types without knowing what
9
+ * it is for (a pairing code), characters of an alphabet, which is guessable
10
+ * and which the app then holds off another way, such as a rate limit per
11
+ * address.
12
+ */
13
+ export type LambderOneShotSecretKind = {
14
+ shape: "code";
15
+ /** The characters a code is drawn from, each once. Default: the ten digits. */
16
+ alphabet?: string;
17
+ length: number;
18
+ ttlSeconds: number;
19
+ /** Wrong tries the code survives; the try that would pass this refuses the code as exhausted, right or wrong. */
20
+ maxAttempts: number;
21
+ } | {
22
+ shape: "token";
23
+ /** Random bytes behind the token, which is their base64url. Default: 32. */
24
+ bytes?: number;
25
+ alphabet?: undefined;
26
+ length?: undefined;
27
+ ttlSeconds: number;
28
+ } | {
29
+ shape: "token";
30
+ /** The characters the token is drawn from, each once, for a token somebody types. */
31
+ alphabet: string;
32
+ length: number;
33
+ bytes?: undefined;
34
+ ttlSeconds: number;
35
+ };
36
+ export type LambderOneShotSecretsOptions<TKinds extends Record<string, LambderOneShotSecretKind>> = {
37
+ store: LambderOneShotSecretStore;
38
+ /** Keys every digest at rest, so a copied store cannot be attacked offline. Server side only. */
39
+ secret: string;
40
+ kinds: TKinds;
41
+ /** The clock, in epoch milliseconds. Default: Date.now. */
42
+ now?: () => number;
43
+ };
44
+ export type LambderOneShotIssueResult = {
45
+ issued: true;
46
+ /** The secret, as it leaves this module the one time it does: a code as written, a token as base64url. */
47
+ plaintext: string;
48
+ /** Epoch seconds. */
49
+ expiresAt: number;
50
+ } | {
51
+ issued: false;
52
+ refused: "cooldown"; /** Epoch seconds when the cooldown ends. */
53
+ retryAt: number;
54
+ };
55
+ export type LambderOneShotRedeemResult =
56
+ /** The secret is the one out: spent now, with the scope it proved and what the app stored beside it. */
57
+ {
58
+ state: "accepted";
59
+ scope: string;
60
+ meta: Record<string, string>;
61
+ issuedAt: number;
62
+ }
63
+ /** Not the code that is out; the try was counted. */
64
+ | {
65
+ state: "wrong";
66
+ attemptsLeft: number;
67
+ }
68
+ /** The code that is out has run out of time; ask for a new one. */
69
+ | {
70
+ state: "expired";
71
+ }
72
+ /** The code that is out has run out of tries, this one included; ask for a new one. */
73
+ | {
74
+ state: "exhausted";
75
+ }
76
+ /** Nothing is out for this scope or this value: never issued, spent, replaced, retired, or unknown. */
77
+ | {
78
+ state: "none";
79
+ };
80
+ /** The names of the kinds a scoped code is redeemed under. */
81
+ export type LambderOneShotCodeKindNames<TKinds> = {
82
+ [K in keyof TKinds]: TKinds[K] extends {
83
+ shape: "code";
84
+ } ? K : never;
85
+ }[keyof TKinds] & string;
86
+ /** The names of the kinds a token is redeemed under, by value. */
87
+ export type LambderOneShotTokenKindNames<TKinds> = {
88
+ [K in keyof TKinds]: TKinds[K] extends {
89
+ shape: "token";
90
+ } ? K : never;
91
+ }[keyof TKinds] & string;
92
+ /**
93
+ * Codes and tokens an app hands out once and takes back once, over a store
94
+ * that settles their races.
95
+ *
96
+ * ```ts
97
+ * const secrets = new LambderOneShotSecrets({
98
+ * store: new LambderDdbOneShotSecretStore({ tableName: "app-policies" }),
99
+ * secret: ONE_SHOT_SECRET,
100
+ * kinds: {
101
+ * emailCode: { shape: "code", length: 6, ttlSeconds: 600, maxAttempts: 5 },
102
+ * activationLink: { shape: "token", ttlSeconds: 48 * 3600 },
103
+ * },
104
+ * });
105
+ *
106
+ * const issued = await secrets.issue("emailCode", `register:${email}`, { cooldownSeconds: 30 });
107
+ * if(issued.issued) await sendEmail(email, issued.plaintext);
108
+ *
109
+ * const redeemed = await secrets.redeem("emailCode", `register:${email}`, typedCode);
110
+ * if(redeemed.state !== "accepted") refuse(...);
111
+ * ```
112
+ *
113
+ * The store holds digests only, keyed under the app's secret with the kind
114
+ * and the scope folded in: a code issued to two scopes never collides, and a
115
+ * code cannot be replayed against another kind or scope. The plaintext leaves
116
+ * once, from `issue`; nothing here logs it or stores it.
117
+ */
118
+ export declare class LambderOneShotSecrets<TKinds extends Record<string, LambderOneShotSecretKind>> {
119
+ private readonly store;
120
+ private readonly secret;
121
+ private readonly kinds;
122
+ private readonly now;
123
+ constructor(options: LambderOneShotSecretsOptions<TKinds>);
124
+ private nowSeconds;
125
+ private kindOf;
126
+ /**
127
+ * The digest a secret rests as. A code's carries its kind and scope, so
128
+ * the same code issued to two scopes never collides and a code cannot be
129
+ * replayed against another; a token's carries its kind, since a token is
130
+ * found by its digest alone, so two scopes can draw the same one, and the
131
+ * store's claim on the digest at issue is what keeps them apart. The
132
+ * fields are joined escaped, so no two distinct field lists produce one
133
+ * string.
134
+ */
135
+ private digestOf;
136
+ /**
137
+ * Mints one secret for the scope, retiring whatever the scope held, and
138
+ * answers the plaintext: the one time it exists outside the caller's
139
+ * hands. With `cooldownSeconds`, a scope whose current secret was issued
140
+ * less than that ago is refused instead, with the second it may ask
141
+ * again; of two callers racing past the cooldown, exactly one is issued.
142
+ * `meta` is what the app wants back at redemption: an identity, an
143
+ * issuing organization, as small strings.
144
+ *
145
+ * A secret whose digest another scope holds is drawn again, up to
146
+ * MAX_DRAWS times; past that the kind's alphabet and length leave too few
147
+ * secrets for the ones out at once, and the issue throws.
148
+ */
149
+ issue(kind: keyof TKinds & string, scope: string, options?: {
150
+ cooldownSeconds?: number;
151
+ meta?: Record<string, string>;
152
+ }): Promise<LambderOneShotIssueResult>;
153
+ /**
154
+ * Redeems a code for its scope. The try is counted before the code is
155
+ * looked at, in the write that reads the digest, so tries sent together
156
+ * are all counted; a right code past the ceiling is refused as exhausted.
157
+ * An accepted code is spent in the same call, exactly once.
158
+ */
159
+ redeem(kind: LambderOneShotCodeKindNames<TKinds>, scope: string, candidate: string): Promise<LambderOneShotRedeemResult>;
160
+ /** Redeems a token by its value: found by its digest, spent exactly once. A token of another kind, or none, is "none". */
161
+ redeemToken(kind: LambderOneShotTokenKindNames<TKinds>, candidate: string): Promise<LambderOneShotRedeemResult>;
162
+ /** Ends whatever the scope holds: after the thing it proved is settled another way, or when what was sent never arrived. */
163
+ retire(scope: string): Promise<void>;
164
+ /** Spends the record; a redemption racing this one and winning makes it "none". */
165
+ private accept;
166
+ }
@@ -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
- const webCrypto = await this.ready();
66
- const encoder = new TextEncoder();
67
- const hmacKey = await webCrypto.subtle.importKey("raw", encoder.encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
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 constantTimeEqual(a, b);
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 constantTimeEqual(a, b);
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
- * tests/store-conformance asserts the rules against every implementation.
42
- * Four are easy to get wrong:
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 {};