lambder 8.1.1 → 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 +164 -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/ContractTypePrinter.d.ts +39 -9
- package/dist/build/ContractTypePrinter.js +89 -31
- package/dist/build/generatedTables.d.ts +72 -0
- package/dist/build/generatedTables.js +99 -0
- package/dist/build/writeApiContract.d.ts +2 -1
- package/dist/build/writeApiContract.js +2 -1
- 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 +23 -32
- 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/util/escapeXmlText.d.ts +8 -0
- package/dist/shared/util/escapeXmlText.js +8 -0
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
- package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
- package/dist/shared/wire/LambderUploadObjectFields.js +2 -2
- 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/stores/LambderMemoryUploadBucket.js +2 -2
- 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,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
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
export type LambderSignedClaimsOptions<TClaims> = {
|
|
3
|
+
/** The HMAC key. Server side, and every runtime that verifies; never a browser. */
|
|
4
|
+
secret: string;
|
|
5
|
+
/**
|
|
6
|
+
* Names this kind of token, as the first segment of every one it signs;
|
|
7
|
+
* a token of another version does not verify. Any text without a dot.
|
|
8
|
+
*/
|
|
9
|
+
version: string;
|
|
10
|
+
/** The claims' shape, parsed on the way out and on the way back, so a token never carries a shape the app did not declare. */
|
|
11
|
+
schema: z.ZodType<TClaims>;
|
|
12
|
+
/** The clock `exp` is judged against, in epoch milliseconds. Default: Date.now. */
|
|
13
|
+
now?: () => number;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Refuses a claims type whose `exp` is not a number, at the constructor: the
|
|
17
|
+
* class would judge it as an expiry, find no number, and let the token live
|
|
18
|
+
* for ever. The property name is the message.
|
|
19
|
+
*/
|
|
20
|
+
type LambderExpIsSeconds<TClaims> = TClaims extends {
|
|
21
|
+
exp: infer E;
|
|
22
|
+
} ? ([E] extends [number] ? unknown : {
|
|
23
|
+
readonly "lambder: the exp claim is an expiry in epoch seconds, so the schema must declare it as a number.": never;
|
|
24
|
+
}) : unknown;
|
|
25
|
+
/**
|
|
26
|
+
* One kind of signed token: constructed once with its secret, version and
|
|
27
|
+
* claims schema, it keeps the imported HMAC key and answers `sign` and
|
|
28
|
+
* `verify`. An app declares one instance per kind of token it hands out.
|
|
29
|
+
*
|
|
30
|
+
* The one claim with a meaning here is `exp`, an expiry in epoch seconds,
|
|
31
|
+
* the unit sessions, DynamoDB's TTL and JWTs use. A schema that declares it
|
|
32
|
+
* gets it checked on every verify; a schema without it declares a token that
|
|
33
|
+
* does not expire on its own, which is right where the thing the token names
|
|
34
|
+
* (a request, a message) decides what it may still do.
|
|
35
|
+
*
|
|
36
|
+
* `verify` answers null for every failure alike: a forged signature, another
|
|
37
|
+
* version, malformed text, claims the schema refuses, an `exp` in the past.
|
|
38
|
+
* A caller cannot tell them apart, and so cannot tell a client apart either.
|
|
39
|
+
*/
|
|
40
|
+
export declare class LambderSignedClaims<TClaims extends object> {
|
|
41
|
+
private readonly secret;
|
|
42
|
+
private readonly version;
|
|
43
|
+
private readonly schema;
|
|
44
|
+
private readonly now;
|
|
45
|
+
/** Imported on first use and kept: signing and verifying share one key object for the life of the instance. */
|
|
46
|
+
private keyPromise;
|
|
47
|
+
constructor(options: LambderSignedClaimsOptions<TClaims> & LambderExpIsSeconds<TClaims>);
|
|
48
|
+
/** The token for these claims, which the schema parses first. `exp`, when the schema has it, is epoch seconds. */
|
|
49
|
+
sign(claims: TClaims): Promise<string>;
|
|
50
|
+
/**
|
|
51
|
+
* The claims, when the token is this version, signed under this secret,
|
|
52
|
+
* well formed, accepted by the schema and not past its `exp`; null
|
|
53
|
+
* otherwise, for every reason alike. `now`, in epoch milliseconds, is the
|
|
54
|
+
* moment `exp` is judged against for this call; default: the clock's.
|
|
55
|
+
*/
|
|
56
|
+
verify(token: string, options?: {
|
|
57
|
+
now?: number;
|
|
58
|
+
}): Promise<TClaims | null>;
|
|
59
|
+
private key;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The keyed digest of a secret an app stores and later looks up or compares
|
|
63
|
+
* by value: a device's secret, a code sent by email, a pairing code. Keyed
|
|
64
|
+
* under the app's secret rather than a plain hash, so a copied table alone
|
|
65
|
+
* cannot be attacked offline, and deterministic, so a lookup is one indexed
|
|
66
|
+
* read. HMAC-SHA256 as 43 characters of base64url; compare two with
|
|
67
|
+
* constantTimeEquals.
|
|
68
|
+
*/
|
|
69
|
+
export declare const keyedDigest: (secret: string, value: string) => Promise<string>;
|
|
70
|
+
/**
|
|
71
|
+
* A fresh secret from the runtime's cryptographic random source, as base64url
|
|
72
|
+
* (43 characters for the default 32 bytes): the credential a paired device
|
|
73
|
+
* keeps, the token in a link that must not be guessable. Synchronous, since
|
|
74
|
+
* getRandomValues is, on every runtime with a WebCrypto global; a runtime
|
|
75
|
+
* without one is told so.
|
|
76
|
+
*/
|
|
77
|
+
export declare const randomSecret: (bytes?: number) => string;
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { base64UrlToBytes, bytesToBase64Url, isBase64Url } from "./LambderBase64.js";
|
|
2
|
+
import { hmacSha256Of, importHmacKey, resolveWebCrypto } from "./LambderTextDigest.js";
|
|
3
|
+
/**
|
|
4
|
+
* One kind of signed token: constructed once with its secret, version and
|
|
5
|
+
* claims schema, it keeps the imported HMAC key and answers `sign` and
|
|
6
|
+
* `verify`. An app declares one instance per kind of token it hands out.
|
|
7
|
+
*
|
|
8
|
+
* The one claim with a meaning here is `exp`, an expiry in epoch seconds,
|
|
9
|
+
* the unit sessions, DynamoDB's TTL and JWTs use. A schema that declares it
|
|
10
|
+
* gets it checked on every verify; a schema without it declares a token that
|
|
11
|
+
* does not expire on its own, which is right where the thing the token names
|
|
12
|
+
* (a request, a message) decides what it may still do.
|
|
13
|
+
*
|
|
14
|
+
* `verify` answers null for every failure alike: a forged signature, another
|
|
15
|
+
* version, malformed text, claims the schema refuses, an `exp` in the past.
|
|
16
|
+
* A caller cannot tell them apart, and so cannot tell a client apart either.
|
|
17
|
+
*/
|
|
18
|
+
export class LambderSignedClaims {
|
|
19
|
+
secret;
|
|
20
|
+
version;
|
|
21
|
+
schema;
|
|
22
|
+
now;
|
|
23
|
+
/** Imported on first use and kept: signing and verifying share one key object for the life of the instance. */
|
|
24
|
+
keyPromise;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
if (typeof options.secret !== "string" || options.secret.length === 0) {
|
|
27
|
+
throw new Error("Lambder: LambderSignedClaims needs a secret to sign with.");
|
|
28
|
+
}
|
|
29
|
+
if (typeof options.version !== "string" || options.version.length === 0 || options.version.includes(".")) {
|
|
30
|
+
throw new Error(`Lambder: LambderSignedClaims version must be text without a dot, got ${JSON.stringify(options.version)}: it is the first of the token's three dot-separated segments.`);
|
|
31
|
+
}
|
|
32
|
+
this.secret = options.secret;
|
|
33
|
+
this.version = options.version;
|
|
34
|
+
this.schema = options.schema;
|
|
35
|
+
this.now = options.now ?? (() => Date.now());
|
|
36
|
+
}
|
|
37
|
+
/** The token for these claims, which the schema parses first. `exp`, when the schema has it, is epoch seconds. */
|
|
38
|
+
async sign(claims) {
|
|
39
|
+
const body = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(this.schema.parse(claims))));
|
|
40
|
+
const message = `${this.version}.${body}`;
|
|
41
|
+
const webCrypto = await resolveWebCrypto();
|
|
42
|
+
const mac = await webCrypto.subtle.sign("HMAC", await this.key(webCrypto), new TextEncoder().encode(message));
|
|
43
|
+
return `${message}.${bytesToBase64Url(new Uint8Array(mac))}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The claims, when the token is this version, signed under this secret,
|
|
47
|
+
* well formed, accepted by the schema and not past its `exp`; null
|
|
48
|
+
* otherwise, for every reason alike. `now`, in epoch milliseconds, is the
|
|
49
|
+
* moment `exp` is judged against for this call; default: the clock's.
|
|
50
|
+
*/
|
|
51
|
+
async verify(token, options = {}) {
|
|
52
|
+
const parts = token.split(".");
|
|
53
|
+
if (parts.length !== 3 || parts[0] !== this.version)
|
|
54
|
+
return null;
|
|
55
|
+
const [, body, mac] = parts;
|
|
56
|
+
if (!isBase64Url(body) || !isBase64Url(mac))
|
|
57
|
+
return null;
|
|
58
|
+
// The last character of a MAC carries bits decoding ignores, so four
|
|
59
|
+
// spellings decode to the same bytes. Only the one sign() writes is a
|
|
60
|
+
// token: an app that keys anything on the token string (a list of
|
|
61
|
+
// spent tokens, a rate limit) must not meet the same token again
|
|
62
|
+
// under another spelling.
|
|
63
|
+
const macBytes = base64UrlToBytes(mac);
|
|
64
|
+
if (bytesToBase64Url(macBytes) !== mac)
|
|
65
|
+
return null;
|
|
66
|
+
const webCrypto = await resolveWebCrypto();
|
|
67
|
+
const valid = await webCrypto.subtle.verify("HMAC", await this.key(webCrypto), macBytes, new TextEncoder().encode(`${this.version}.${body}`));
|
|
68
|
+
if (!valid)
|
|
69
|
+
return null;
|
|
70
|
+
let claims;
|
|
71
|
+
try {
|
|
72
|
+
claims = this.schema.parse(JSON.parse(new TextDecoder().decode(base64UrlToBytes(body))));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const exp = claims.exp;
|
|
78
|
+
if (typeof exp === "number" && exp * 1000 <= (options.now ?? this.now()))
|
|
79
|
+
return null;
|
|
80
|
+
return claims;
|
|
81
|
+
}
|
|
82
|
+
key(webCrypto) {
|
|
83
|
+
this.keyPromise ??= importHmacKey(webCrypto, this.secret, ["sign", "verify"]);
|
|
84
|
+
return this.keyPromise;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The keyed digest of a secret an app stores and later looks up or compares
|
|
89
|
+
* by value: a device's secret, a code sent by email, a pairing code. Keyed
|
|
90
|
+
* under the app's secret rather than a plain hash, so a copied table alone
|
|
91
|
+
* cannot be attacked offline, and deterministic, so a lookup is one indexed
|
|
92
|
+
* read. HMAC-SHA256 as 43 characters of base64url; compare two with
|
|
93
|
+
* constantTimeEquals.
|
|
94
|
+
*/
|
|
95
|
+
export const keyedDigest = async (secret, value) => bytesToBase64Url(await hmacSha256Of(secret, value));
|
|
96
|
+
/**
|
|
97
|
+
* A fresh secret from the runtime's cryptographic random source, as base64url
|
|
98
|
+
* (43 characters for the default 32 bytes): the credential a paired device
|
|
99
|
+
* keeps, the token in a link that must not be guessable. Synchronous, since
|
|
100
|
+
* getRandomValues is, on every runtime with a WebCrypto global; a runtime
|
|
101
|
+
* without one is told so.
|
|
102
|
+
*/
|
|
103
|
+
export const randomSecret = (bytes = 32) => {
|
|
104
|
+
const webCrypto = globalThis.crypto;
|
|
105
|
+
if (typeof webCrypto?.getRandomValues !== "function") {
|
|
106
|
+
throw new Error("Lambder needs crypto.getRandomValues in this runtime to mint a secret. Every browser provides it; Node 20+ provides it as globalThis.crypto.");
|
|
107
|
+
}
|
|
108
|
+
return bytesToBase64Url(webCrypto.getRandomValues(new Uint8Array(bytes)));
|
|
109
|
+
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SHA-256 through WebCrypto: the
|
|
3
|
-
* crypto hashes bearer secrets with
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* SHA-256 and HMAC-SHA256 through WebCrypto: the digests every layer shares.
|
|
3
|
+
* The session crypto hashes bearer secrets with the first and partitions
|
|
4
|
+
* session keys with the second, the rate-limit engine folds an over-long
|
|
5
|
+
* tracker key, signed claims and keyed digests are HMACs under an app's
|
|
6
|
+
* secret, so every key space is built from the same primitives on every
|
|
7
|
+
* runtime (browsers on a secure context, Node 20+, edge runtimes); an
|
|
8
|
+
* upload's checksum is the same digest over the file's bytes.
|
|
7
9
|
*/
|
|
8
10
|
/** Lowercase hex of a byte array, two characters per byte. */
|
|
9
11
|
export declare const bytesToHexString: (bytes: Uint8Array) => string;
|
|
@@ -17,3 +19,15 @@ export declare const resolveWebCrypto: () => Promise<Crypto>;
|
|
|
17
19
|
export declare const sha256HexOf: (text: string) => Promise<string>;
|
|
18
20
|
/** The SHA-256 digest of `bytes`, as base64: the form object storage checks an upload's checksum in. */
|
|
19
21
|
export declare const sha256Base64Of: (bytes: Uint8Array) => Promise<string>;
|
|
22
|
+
/** An HMAC-SHA256 key over `secret` (UTF-8), for `usages`; a holder that signs often keeps the key rather than importing it per call. */
|
|
23
|
+
export declare const importHmacKey: (webCrypto: Crypto, secret: string, usages?: KeyUsage[]) => Promise<CryptoKey>;
|
|
24
|
+
/** HMAC-SHA256 of `text` under `secret` (both UTF-8), as bytes. */
|
|
25
|
+
export declare const hmacSha256Of: (secret: string, text: string) => Promise<Uint8Array>;
|
|
26
|
+
/**
|
|
27
|
+
* Length-aware, timing-neutral string comparison: no early exit on the first
|
|
28
|
+
* differing character, so how long it takes says nothing about where two
|
|
29
|
+
* digests part. For comparing a stored digest with the digest of a candidate;
|
|
30
|
+
* a signature is verified by WebCrypto, which compares in constant time
|
|
31
|
+
* itself.
|
|
32
|
+
*/
|
|
33
|
+
export declare const constantTimeEquals: (a: string, b: string) => boolean;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { bytesToBase64 } from "./LambderBase64.js";
|
|
2
2
|
import { getCrypto } from "./LambderNodeModules.js";
|
|
3
3
|
/**
|
|
4
|
-
* SHA-256 through WebCrypto: the
|
|
5
|
-
* crypto hashes bearer secrets with
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* SHA-256 and HMAC-SHA256 through WebCrypto: the digests every layer shares.
|
|
5
|
+
* The session crypto hashes bearer secrets with the first and partitions
|
|
6
|
+
* session keys with the second, the rate-limit engine folds an over-long
|
|
7
|
+
* tracker key, signed claims and keyed digests are HMACs under an app's
|
|
8
|
+
* secret, so every key space is built from the same primitives on every
|
|
9
|
+
* runtime (browsers on a secure context, Node 20+, edge runtimes); an
|
|
10
|
+
* upload's checksum is the same digest over the file's bytes.
|
|
9
11
|
*/
|
|
10
12
|
/** Lowercase hex of a byte array, two characters per byte. */
|
|
11
13
|
export const bytesToHexString = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
@@ -38,3 +40,26 @@ export const sha256Base64Of = async (bytes) => {
|
|
|
38
40
|
const webCrypto = await resolveWebCrypto();
|
|
39
41
|
return bytesToBase64(new Uint8Array(await webCrypto.subtle.digest("SHA-256", bytes)));
|
|
40
42
|
};
|
|
43
|
+
/** An HMAC-SHA256 key over `secret` (UTF-8), for `usages`; a holder that signs often keeps the key rather than importing it per call. */
|
|
44
|
+
export const importHmacKey = async (webCrypto, secret, usages = ["sign"]) => await webCrypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, usages);
|
|
45
|
+
/** HMAC-SHA256 of `text` under `secret` (both UTF-8), as bytes. */
|
|
46
|
+
export const hmacSha256Of = async (secret, text) => {
|
|
47
|
+
const webCrypto = await resolveWebCrypto();
|
|
48
|
+
const key = await importHmacKey(webCrypto, secret);
|
|
49
|
+
return new Uint8Array(await webCrypto.subtle.sign("HMAC", key, new TextEncoder().encode(text)));
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Length-aware, timing-neutral string comparison: no early exit on the first
|
|
53
|
+
* differing character, so how long it takes says nothing about where two
|
|
54
|
+
* digests part. For comparing a stored digest with the digest of a candidate;
|
|
55
|
+
* a signature is verified by WebCrypto, which compares in constant time
|
|
56
|
+
* itself.
|
|
57
|
+
*/
|
|
58
|
+
export const constantTimeEquals = (a, b) => {
|
|
59
|
+
if (a.length !== b.length)
|
|
60
|
+
return false;
|
|
61
|
+
let difference = 0;
|
|
62
|
+
for (let i = 0; i < a.length; i += 1)
|
|
63
|
+
difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
64
|
+
return difference === 0;
|
|
65
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a value survives JSON unchanged, and so can be written into a
|
|
3
|
+
* generated module as the data it is: strings, finite numbers, booleans,
|
|
4
|
+
* null, and arrays and plain objects of them. Anything else (a function, a
|
|
5
|
+
* class instance such as a zod schema, a symbol, a bigint, NaN, undefined
|
|
6
|
+
* inside a container) is code or a shape JSON would silently rewrite, and
|
|
7
|
+
* the caller is told where it sits.
|
|
8
|
+
*/
|
|
9
|
+
export declare const assertPlainData: (value: unknown, subject: string) => void;
|