@spfn/auth 0.2.0-beta.9 → 0.2.0-beta.91
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/LICENSE +1 -1
- package/README.md +1007 -1739
- package/dist/authenticate-DlTGaBT8.d.ts +1403 -0
- package/dist/client-proof.d.ts +606 -0
- package/dist/client-proof.js +1842 -0
- package/dist/client-proof.js.map +1 -0
- package/dist/config.d.ts +487 -39
- package/dist/config.js +243 -29
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +208 -3
- package/dist/errors.js +140 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +392 -110
- package/dist/index.js +186 -7
- package/dist/index.js.map +1 -1
- package/dist/nextjs/api.js +591 -61
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/client.d.ts +28 -0
- package/dist/nextjs/client.js +80 -0
- package/dist/nextjs/client.js.map +1 -0
- package/dist/nextjs/server.d.ts +92 -3
- package/dist/nextjs/server.js +288 -24
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +2618 -1092
- package/dist/server.js +6553 -1500
- package/dist/server.js.map +1 -1
- package/dist/session-DTHahDQ9.d.ts +53 -0
- package/dist/types-DYyhze28.d.ts +98 -0
- package/dist/wire-version-CtzMKvBB.d.ts +134 -0
- package/migrations/20251125021229_premium_famine/snapshot.json +2641 -0
- package/migrations/20260225130050_smooth_the_fury/migration.sql +3 -0
- package/migrations/20260225130050_smooth_the_fury/snapshot.json +2686 -0
- package/migrations/20260308141417_deep_iceman/migration.sql +11 -0
- package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
- package/migrations/20260308151309_perfect_deathbird/migration.sql +3 -0
- package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
- package/migrations/20260308201135_concerned_rawhide_kid/migration.sql +5 -0
- package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
- package/migrations/20260629103209_lethal_lifeguard/migration.sql +32 -0
- package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
- package/migrations/20260709073531_easy_hardball/migration.sql +24 -0
- package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
- package/migrations/20260714081434_glossy_major_mapleleaf/migration.sql +1 -0
- package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
- package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
- package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
- package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
- package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
- package/migrations/20260805143152_vengeful_ravenous/migration.sql +4 -0
- package/migrations/20260805143152_vengeful_ravenous/snapshot.json +3190 -0
- package/package.json +60 -46
- package/dist/dto-CRlgoCP5.d.ts +0 -645
- package/migrations/meta/0000_snapshot.json +0 -1632
- package/migrations/meta/_journal.json +0 -13
- /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { KeyObject } from 'node:crypto';
|
|
2
|
+
import { C as ClientProofRefusal } from './wire-version-CtzMKvBB.js';
|
|
3
|
+
export { a as CLIENT_IDENTITY_HEADERS, b as CLIENT_KINDS, c as ClientIdentity, d as ClientKind, e as ClientProofErrorCode, S as SERVER_CONTRACT_HEADERS, f as applyServerContractHeaders, i as isAppKind, g as isContractVersionSupported, j as judgeClientIdentity, n as newHexId, r as readClientIdentity, s as serverContractHeaders } from './wire-version-CtzMKvBB.js';
|
|
4
|
+
import { MiddlewareHandler, Context } from 'hono';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SPFN-CANON-JSON-1 — the canonical JSON form the mobile contract pins.
|
|
8
|
+
*
|
|
9
|
+
* The rules (contracts/mobile/spfn-mobile-contract.json `canonicalJson`):
|
|
10
|
+
* - object keys sorted ascending by UTF-8 byte sequence
|
|
11
|
+
* - no insignificant whitespace
|
|
12
|
+
* - numbers are signed 64-bit integers only
|
|
13
|
+
* - string escapes: `"` and `\` escaped; C0 controls use \b \f \n \r \t where
|
|
14
|
+
* defined and lowercase \u00XX otherwise; every other scalar is emitted
|
|
15
|
+
* literally as UTF-8
|
|
16
|
+
* - absent optional fields are omitted, never null
|
|
17
|
+
*
|
|
18
|
+
* JSON.parse cannot implement this: it loses int64 precision, accepts duplicate
|
|
19
|
+
* keys and (in V8) raw control characters, so both directions are hand-rolled.
|
|
20
|
+
* A proof binds the received bytes — parse-then-re-encode equality is what makes
|
|
21
|
+
* canonicity a rule a client can actually break.
|
|
22
|
+
*
|
|
23
|
+
* @module server/client-proof/canonical-json
|
|
24
|
+
*/
|
|
25
|
+
type CanonicalObject = Map<string, CanonicalValue>;
|
|
26
|
+
type CanonicalValue = null | boolean | bigint | string | CanonicalValue[] | CanonicalObject;
|
|
27
|
+
/**
|
|
28
|
+
* Parse failures carry the code the mobile conformance fixtures name
|
|
29
|
+
* (Contracts/fixtures/canonical/rejects.json), so the fixtures can assert on it.
|
|
30
|
+
*/
|
|
31
|
+
type CanonicalJsonErrorCode = 'DUPLICATE_KEY' | 'NON_INTEGER_NUMBER' | 'TRAILING_CONTENT' | 'UNEXPECTED_END' | 'INVALID_TOKEN' | 'INVALID_ESCAPE' | 'INTEGER_OUT_OF_RANGE' | 'INVALID_UTF8';
|
|
32
|
+
declare class CanonicalJsonError extends Error {
|
|
33
|
+
readonly code: CanonicalJsonErrorCode;
|
|
34
|
+
constructor(code: CanonicalJsonErrorCode);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse bytes as SPFN-CANON-JSON-1.
|
|
38
|
+
*
|
|
39
|
+
* Arbitrary whitespace and key order are accepted here — parsing alone proves
|
|
40
|
+
* nothing about canonicity. Callers that must enforce it re-encode the result
|
|
41
|
+
* and compare bytes (see `isCanonicalBytes`).
|
|
42
|
+
*/
|
|
43
|
+
declare function parseCanonicalJson(bytes: Uint8Array): CanonicalValue;
|
|
44
|
+
/** True when `bytes` are exactly the canonical encoding of the value they parse to. */
|
|
45
|
+
declare function isCanonicalBytes(bytes: Uint8Array, value: CanonicalValue): boolean;
|
|
46
|
+
/** Encode a value as SPFN-CANON-JSON-1 bytes. */
|
|
47
|
+
declare function encodeCanonicalJson(value: CanonicalValue): Uint8Array;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* SPFN-PROOF-INPUT-1 — proof-input assembly and verification for clientProofV1.
|
|
51
|
+
*
|
|
52
|
+
* The proof input is 8 fields joined by `\n` in fixed order: profile, method,
|
|
53
|
+
* path, clientId, keyId, nonce, issuedAtMillis, bodySha256. Any C0 control
|
|
54
|
+
* character in any field is a hard refusal (the separator would otherwise be
|
|
55
|
+
* ambiguous), never something to escape. The proof is an ECDSA P-256 signature
|
|
56
|
+
* with SHA-256 over the canonical input's UTF-8 bytes, wire-encoded as the raw
|
|
57
|
+
* `r ‖ s` 64 bytes in base16-lower (128 hex characters). DER is never accepted
|
|
58
|
+
* on the wire: a platform signer that emits DER (Java `Signature`) converts to
|
|
59
|
+
* raw before sending. Low-S normalization is not required — uniqueness is owned
|
|
60
|
+
* by the nonce and replay window, so signature malleability cannot replay.
|
|
61
|
+
*
|
|
62
|
+
* @module server/client-proof/proof
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/** The only auth profile this module implements. */
|
|
66
|
+
declare const CLIENT_PROOF_PROFILE = "clientProofV1";
|
|
67
|
+
/** `bodySha256` when an operation carries no body: 64 zero characters. */
|
|
68
|
+
declare const ABSENT_BODY_SHA256: string;
|
|
69
|
+
/** The contract's `clientProofV1.replayWindowMillis`. */
|
|
70
|
+
declare const DEFAULT_REPLAY_WINDOW_MILLIS = 300000;
|
|
71
|
+
/** Raw `r ‖ s`: two 32-byte big-endian integers, always exactly this long. */
|
|
72
|
+
declare const PROOF_SIGNATURE_BYTES = 64;
|
|
73
|
+
/** The wire form is base16-lower of the raw signature: 128 hex characters. */
|
|
74
|
+
declare const PROOF_SIGNATURE_HEX_LENGTH: number;
|
|
75
|
+
interface ClientProofInput {
|
|
76
|
+
method: string;
|
|
77
|
+
path: string;
|
|
78
|
+
clientId: string;
|
|
79
|
+
keyId: string;
|
|
80
|
+
nonce: string;
|
|
81
|
+
issuedAtMillis: bigint;
|
|
82
|
+
bodySha256: string;
|
|
83
|
+
}
|
|
84
|
+
/** A C0 control character appeared in a proof field. */
|
|
85
|
+
declare class ProofInputError extends Error {
|
|
86
|
+
constructor();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The canonical proof-input string the signature is taken over.
|
|
90
|
+
*
|
|
91
|
+
* @throws ProofInputError when any field contains a C0 control character.
|
|
92
|
+
*/
|
|
93
|
+
declare function canonicalProofInput(input: ClientProofInput): string;
|
|
94
|
+
/**
|
|
95
|
+
* The contract's public-key representation — SPKI DER, base64 (the same
|
|
96
|
+
* representation `user_public_keys` and the web ES256 path store) — as a key
|
|
97
|
+
* object. Anything that is not a P-256 EC key is refused at parse time, so a
|
|
98
|
+
* key that could never verify a proof is never registered.
|
|
99
|
+
*
|
|
100
|
+
* @throws when the input is not base64 SPKI DER naming a P-256 key.
|
|
101
|
+
*/
|
|
102
|
+
declare function parseClientProofPublicKey(spkiDerBase64: string): KeyObject;
|
|
103
|
+
/**
|
|
104
|
+
* Verifies a presented proof against `input` and a registered public key.
|
|
105
|
+
*
|
|
106
|
+
* The input is assembled first, so a C0 control character throws no matter
|
|
107
|
+
* what was presented — an unassemblable input is a contract violation, never
|
|
108
|
+
* a proof answer. Then the wire-format gate: a value that is not exactly 128
|
|
109
|
+
* lowercase hex characters — a DER signature, a truncated one, uppercase hex —
|
|
110
|
+
* is invalid before any cryptography happens.
|
|
111
|
+
*
|
|
112
|
+
* @throws ProofInputError when an input field contains a C0 control character.
|
|
113
|
+
*/
|
|
114
|
+
declare function verifyClientProof(input: ClientProofInput, presentedProof: string, publicKey: KeyObject): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Signs `input` with a PKCS#8 DER base64 private key, producing the wire form
|
|
117
|
+
* (raw `r ‖ s`, base16-lower).
|
|
118
|
+
*
|
|
119
|
+
* The verifying half's counterpart, here for tests and dev clients — a
|
|
120
|
+
* production signer lives in the mobile SDKs against hardware-held keys.
|
|
121
|
+
*/
|
|
122
|
+
declare function signClientProof(input: ClientProofInput, privateKeyPkcs8DerBase64: string): string;
|
|
123
|
+
/** Lowercase base16 SHA-256 of `bytes`. */
|
|
124
|
+
declare function sha256Hex(bytes: Uint8Array): string;
|
|
125
|
+
|
|
126
|
+
/** Millisecond clock. Injectable so expiry paths are testable without waiting. */
|
|
127
|
+
interface ClientProofClock {
|
|
128
|
+
nowMillis(): number;
|
|
129
|
+
}
|
|
130
|
+
declare function systemClock(): ClientProofClock;
|
|
131
|
+
/** A clock a test (or the dev control surface) can move forward. */
|
|
132
|
+
declare class TestClock implements ClientProofClock {
|
|
133
|
+
private millis;
|
|
134
|
+
constructor(millis: number);
|
|
135
|
+
nowMillis(): number;
|
|
136
|
+
advance(byMillis: number): void;
|
|
137
|
+
}
|
|
138
|
+
/** What `stats()` reports. Counters only; nothing a request carried. */
|
|
139
|
+
interface ClientProofStats {
|
|
140
|
+
requestCount: number;
|
|
141
|
+
handshakeCount: number;
|
|
142
|
+
echoCount: number;
|
|
143
|
+
itemsListCount: number;
|
|
144
|
+
refusalCount: number;
|
|
145
|
+
liveSessionCount: number;
|
|
146
|
+
spentNonceCount: number;
|
|
147
|
+
}
|
|
148
|
+
interface ClientProofStateOptions {
|
|
149
|
+
/**
|
|
150
|
+
* keyId → registered public key, as SPKI DER base64. The private half
|
|
151
|
+
* never reaches the server: a client generates its keypair (hardware-held
|
|
152
|
+
* on mobile) and only the public key is registered — at construction here,
|
|
153
|
+
* or later through `registerPublicKey` (the dev `/control/register-key`
|
|
154
|
+
* route).
|
|
155
|
+
*/
|
|
156
|
+
publicKeys: Record<string, string>;
|
|
157
|
+
clock?: ClientProofClock;
|
|
158
|
+
/** @default 600000 */
|
|
159
|
+
sessionTtlMillis?: number;
|
|
160
|
+
/** The contract's replay window. @default 300000 */
|
|
161
|
+
replayWindowMillis?: number;
|
|
162
|
+
}
|
|
163
|
+
declare const DEFAULT_SESSION_TTL_MILLIS = 600000;
|
|
164
|
+
declare class ClientProofState {
|
|
165
|
+
readonly replayWindowMillis: number;
|
|
166
|
+
private readonly clock;
|
|
167
|
+
private readonly initialPublicKeys;
|
|
168
|
+
private readonly publicKeys;
|
|
169
|
+
private readonly sessions;
|
|
170
|
+
/** The replay ledger — the shared memory implementation, used dev-only here. */
|
|
171
|
+
private readonly spentNonces;
|
|
172
|
+
private readonly revokedKeyIds;
|
|
173
|
+
private readonly holds;
|
|
174
|
+
private readonly initialSessionTtlMillis;
|
|
175
|
+
private sessionTtlMillis;
|
|
176
|
+
private requestCount;
|
|
177
|
+
private handshakeCount;
|
|
178
|
+
private echoCount;
|
|
179
|
+
private itemsListCount;
|
|
180
|
+
private refusalCount;
|
|
181
|
+
constructor(options: ClientProofStateOptions);
|
|
182
|
+
/**
|
|
183
|
+
* Registers (or replaces) the public key `keyId` presents proofs under.
|
|
184
|
+
*
|
|
185
|
+
* @throws when the key is not base64 SPKI DER naming a P-256 key.
|
|
186
|
+
*/
|
|
187
|
+
registerPublicKey(keyId: string, publicKeySpkiDerBase64: string): void;
|
|
188
|
+
/**
|
|
189
|
+
* Runs the contract's checks in the contract's order and returns the
|
|
190
|
+
* refusal, or null when the request is admitted (spending its nonce).
|
|
191
|
+
*/
|
|
192
|
+
admit(args: {
|
|
193
|
+
clientId: string;
|
|
194
|
+
keyId: string;
|
|
195
|
+
presentedSessionId: string | null;
|
|
196
|
+
requiresSession: boolean;
|
|
197
|
+
proofInput: ClientProofInput;
|
|
198
|
+
presentedProof: string;
|
|
199
|
+
}): ClientProofRefusal | null;
|
|
200
|
+
/** Opens a session and returns its id and the expiry the server advertises. */
|
|
201
|
+
openSession(clientId: string, keyId: string): {
|
|
202
|
+
sessionId: string;
|
|
203
|
+
expiresAtMillis: number;
|
|
204
|
+
};
|
|
205
|
+
/** Test hook: installs a session with a chosen id (wire-fixture replays). */
|
|
206
|
+
seedSession(sessionId: string, clientId: string, keyId: string, expiresAtMillis: number): void;
|
|
207
|
+
/** Drops every session, as a restart would. Advertised expiries stay told. */
|
|
208
|
+
expireSessions(): void;
|
|
209
|
+
/** Revokes a key and drops the sessions it opened. */
|
|
210
|
+
revokeKey(keyId: string): void;
|
|
211
|
+
setSessionTtlMillis(millis: number): void;
|
|
212
|
+
/** Returns the state to how it started, counters and registered keys included. */
|
|
213
|
+
reset(): void;
|
|
214
|
+
/** Makes the next `count` requests to `path` wait `millis` before processing. */
|
|
215
|
+
holdPath(path: string, millis: number, count: number): void;
|
|
216
|
+
/** Consumes one configured delay for `path`; returns how long to wait, or 0. */
|
|
217
|
+
takeHoldMillis(path: string): number;
|
|
218
|
+
recordRequest(): void;
|
|
219
|
+
recordOperation(operationId: string): void;
|
|
220
|
+
recordRefusal(): void;
|
|
221
|
+
stats(): ClientProofStats;
|
|
222
|
+
nowMillis(): number;
|
|
223
|
+
/** The clock, exposed for the dev control surface's advance-clock route. */
|
|
224
|
+
get clockRef(): ClientProofClock;
|
|
225
|
+
/**
|
|
226
|
+
* Drops what can no longer affect an answer. The nonce predicate is the
|
|
227
|
+
* exact negation of the window check in `admit`: an entry is dropped only
|
|
228
|
+
* once a proof carrying that issuedAtMillis would be refused as expired
|
|
229
|
+
* anyway. Dropping one moment earlier would let a nonce inside the window
|
|
230
|
+
* be spent twice.
|
|
231
|
+
*/
|
|
232
|
+
private prune;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The checks between a clientProofV1 request arriving and being applied.
|
|
237
|
+
*
|
|
238
|
+
* Shape first, then the profile allowlist, then the proof. That order is
|
|
239
|
+
* forced: none of the proof checks can run until the fields they read are
|
|
240
|
+
* known to be present and the body is known to be the bytes the digest is
|
|
241
|
+
* supposed to cover. The order *inside* the proof checks is the contract's and
|
|
242
|
+
* lives in `ClientProofState.admit`.
|
|
243
|
+
*
|
|
244
|
+
* @module server/client-proof/admission
|
|
245
|
+
*/
|
|
246
|
+
|
|
247
|
+
/** D23 wire-header names, ratified as proposed by the mobile dev bundle. */
|
|
248
|
+
declare const CLIENT_PROOF_HEADERS: {
|
|
249
|
+
readonly profile: "x-spfn-auth-profile";
|
|
250
|
+
readonly clientId: "x-spfn-client-id";
|
|
251
|
+
readonly keyId: "x-spfn-key-id";
|
|
252
|
+
readonly nonce: "x-spfn-nonce";
|
|
253
|
+
readonly issuedAtMillis: "x-spfn-issued-at";
|
|
254
|
+
readonly proof: "x-spfn-proof";
|
|
255
|
+
readonly session: "x-spfn-session";
|
|
256
|
+
};
|
|
257
|
+
declare const CLIENT_PROOF_CONTENT_TYPE = "application/json";
|
|
258
|
+
/** The contract header fields one request presented. */
|
|
259
|
+
interface ClientProofCredentials {
|
|
260
|
+
profile: string;
|
|
261
|
+
clientId: string;
|
|
262
|
+
keyId: string;
|
|
263
|
+
nonce: string;
|
|
264
|
+
issuedAtMillis: bigint;
|
|
265
|
+
proof: string;
|
|
266
|
+
sessionId: string | null;
|
|
267
|
+
}
|
|
268
|
+
type Admission = {
|
|
269
|
+
admitted: false;
|
|
270
|
+
refusal: ClientProofRefusal;
|
|
271
|
+
} | {
|
|
272
|
+
admitted: true;
|
|
273
|
+
value: CanonicalValue;
|
|
274
|
+
credentials: ClientProofCredentials;
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* Runs every check for one operation over already-read body bytes.
|
|
278
|
+
*
|
|
279
|
+
* `path` must be the operation's contract path (what the client signed), not a
|
|
280
|
+
* proxied or rewritten one.
|
|
281
|
+
*/
|
|
282
|
+
declare function admitClientProofRequest(args: {
|
|
283
|
+
state: ClientProofState;
|
|
284
|
+
headers: Headers;
|
|
285
|
+
method: string;
|
|
286
|
+
path: string;
|
|
287
|
+
requiresSession: boolean;
|
|
288
|
+
body: Uint8Array;
|
|
289
|
+
}): Admission;
|
|
290
|
+
/**
|
|
291
|
+
* The contract header fields, or null when any is absent or malformed.
|
|
292
|
+
*
|
|
293
|
+
* Fetch `Headers` folds a repeated field into one comma-joined value, so
|
|
294
|
+
* "sent more than once" is not directly observable here; a folded value fails
|
|
295
|
+
* either the issuedAt grammar or proof verification instead.
|
|
296
|
+
*
|
|
297
|
+
* Exported for the authenticate middleware's profile path, which runs the
|
|
298
|
+
* same shape checks over arbitrary routes.
|
|
299
|
+
*/
|
|
300
|
+
declare function readCredentials(headers: Headers): ClientProofCredentials | null;
|
|
301
|
+
/** Exported for the authenticate middleware's profile path. */
|
|
302
|
+
declare function isRequestContentType(value: string | null): boolean;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The ledger key. `JSON.stringify` of the pair, so no crafted clientId/nonce
|
|
306
|
+
* concatenation can collide with another pair — the fields are checked for C0
|
|
307
|
+
* controls only later, at proof verification, so the key must be unambiguous
|
|
308
|
+
* for arbitrary strings.
|
|
309
|
+
*/
|
|
310
|
+
declare function replayLedgerKey(clientId: string, nonce: string): string;
|
|
311
|
+
/**
|
|
312
|
+
* What the middleware's replay ledger must answer. Both methods may reject;
|
|
313
|
+
* the caller refuses the request when they do (fail-closed).
|
|
314
|
+
*/
|
|
315
|
+
interface ClientProofReplayStore {
|
|
316
|
+
/** True when (clientId, nonce) was already spent inside the window. */
|
|
317
|
+
isSpent(clientId: string, nonce: string): Promise<boolean>;
|
|
318
|
+
/**
|
|
319
|
+
* Records the pair as spent. False when it was already spent — the caller
|
|
320
|
+
* lost a race and must answer PROOF_REPLAYED, not accept twice.
|
|
321
|
+
*/
|
|
322
|
+
spend(clientId: string, nonce: string): Promise<boolean>;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* The in-memory ledger — the single implementation of the window semantics.
|
|
326
|
+
*
|
|
327
|
+
* Entries carry the millisecond they were recorded at; `prune` drops an entry
|
|
328
|
+
* only once a proof carrying that timestamp would be refused as expired
|
|
329
|
+
* anyway (the exact negation of the admission window check). All methods are
|
|
330
|
+
* synchronous so `ClientProofState.admit` can stay atomic on Node's single
|
|
331
|
+
* thread.
|
|
332
|
+
*/
|
|
333
|
+
declare class MemoryReplayLedger {
|
|
334
|
+
/** replayLedgerKey(...) → the millis it was spent at. */
|
|
335
|
+
private readonly spent;
|
|
336
|
+
isSpent(clientId: string, nonce: string): boolean;
|
|
337
|
+
/** Records the pair at `atMillis`; false when it was already spent. */
|
|
338
|
+
spend(clientId: string, nonce: string, atMillis: number): boolean;
|
|
339
|
+
/** Drops entries older than the window, judged against `nowMillis`. */
|
|
340
|
+
prune(nowMillis: number, windowMillis: number): void;
|
|
341
|
+
get size(): number;
|
|
342
|
+
clear(): void;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The default store: a process-local `MemoryReplayLedger` on the wall clock.
|
|
346
|
+
*
|
|
347
|
+
* Correct for a single process. Behind a multi-instance deployment each
|
|
348
|
+
* instance keeps its own ledger, so a replay against a *different* instance
|
|
349
|
+
* is not seen — that deployment opts into `RedisReplayStore`.
|
|
350
|
+
*/
|
|
351
|
+
declare class MemoryReplayStore implements ClientProofReplayStore {
|
|
352
|
+
private readonly windowMillis;
|
|
353
|
+
private readonly ledger;
|
|
354
|
+
constructor(windowMillis?: number);
|
|
355
|
+
isSpent(clientId: string, nonce: string): Promise<boolean>;
|
|
356
|
+
spend(clientId: string, nonce: string): Promise<boolean>;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The opt-in shared ledger over `getCache()` (ioredis): `SET NX PX <window>`.
|
|
360
|
+
*
|
|
361
|
+
* The key hashes the pair, so arbitrary clientId/nonce strings become short,
|
|
362
|
+
* safe Redis keys with no ambiguity. `PX` makes Redis expire the entry itself
|
|
363
|
+
* exactly when a proof reusing the nonce would pass the window check again.
|
|
364
|
+
*
|
|
365
|
+
* Fail-closed by construction: when the cache is not configured or a command
|
|
366
|
+
* rejects, the error propagates and the caller refuses the request. Nothing
|
|
367
|
+
* here answers "not spent" on a store it could not reach.
|
|
368
|
+
*/
|
|
369
|
+
declare class RedisReplayStore implements ClientProofReplayStore {
|
|
370
|
+
private readonly windowMillis;
|
|
371
|
+
constructor(windowMillis?: number);
|
|
372
|
+
isSpent(clientId: string, nonce: string): Promise<boolean>;
|
|
373
|
+
spend(clientId: string, nonce: string): Promise<boolean>;
|
|
374
|
+
private cache;
|
|
375
|
+
private key;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Installs the replay store the authenticate middleware uses. Pass
|
|
379
|
+
* `new RedisReplayStore()` to opt into the shared ledger; pass null to return
|
|
380
|
+
* to the in-memory default.
|
|
381
|
+
*/
|
|
382
|
+
declare function configureClientProofReplayStore(store: ClientProofReplayStore | null): void;
|
|
383
|
+
/** The configured store, or a lazily created in-memory default. */
|
|
384
|
+
declare function getClientProofReplayStore(): ClientProofReplayStore;
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The mobile dev-contract types and operations, decoded from / encoded to
|
|
388
|
+
* canonical values. Strict on purpose: a missing required field, a wrong type
|
|
389
|
+
* or an unknown field is "not the request type this operation declares".
|
|
390
|
+
*
|
|
391
|
+
* This module is the source of truth for `operations`. The exported contract
|
|
392
|
+
* bundle (`contracts/mobile/spfn-mobile-contract.json`) is generated from it
|
|
393
|
+
* by `contract-bundle.ts`; spfn-mobile consumes that export rather than the
|
|
394
|
+
* other way round.
|
|
395
|
+
*
|
|
396
|
+
* @module server/client-proof/contract-types
|
|
397
|
+
*/
|
|
398
|
+
|
|
399
|
+
interface ContractOperation {
|
|
400
|
+
id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list' | 'auth.enroll.register' | 'auth.enroll.login' | 'auth.enroll.oauthNative' | 'auth.keys.rotate' | 'auth.keys.list' | 'auth.keys.revoke' | 'auth.keys.revokeAll';
|
|
401
|
+
method: 'POST';
|
|
402
|
+
path: string;
|
|
403
|
+
/**
|
|
404
|
+
* How a call is admitted. `clientProofV1` operations run the proof
|
|
405
|
+
* admission order; `none` operations are the unproven class — accepted
|
|
406
|
+
* with neither proof headers nor a session header, because enrollment is
|
|
407
|
+
* called before any key exists to sign with.
|
|
408
|
+
*/
|
|
409
|
+
authProfile: 'clientProofV1' | 'none';
|
|
410
|
+
requiresSession: boolean;
|
|
411
|
+
requestType: string;
|
|
412
|
+
responseType: string;
|
|
413
|
+
summary: string;
|
|
414
|
+
/**
|
|
415
|
+
* The contract version this operation first appeared in. Required, so an
|
|
416
|
+
* operation added later cannot ship without one: omitting it is a compile
|
|
417
|
+
* error rather than a hole a consumer discovers.
|
|
418
|
+
*
|
|
419
|
+
* It is history, not policy. This contract's compatibility policy is
|
|
420
|
+
* `allOrNothing` — one version passes or refuses the whole surface — so
|
|
421
|
+
* nothing here changes a verdict. It exists so a deprecation has somewhere
|
|
422
|
+
* to be recorded, and as the precedent an app contract's `perOperation`
|
|
423
|
+
* policy reads.
|
|
424
|
+
*/
|
|
425
|
+
since: string;
|
|
426
|
+
/**
|
|
427
|
+
* The contract version that marked this operation deprecated, if one has.
|
|
428
|
+
* A deprecated operation is still served: the mark is the notice that opens
|
|
429
|
+
* the grace period before removal.
|
|
430
|
+
*/
|
|
431
|
+
deprecatedIn?: string;
|
|
432
|
+
/**
|
|
433
|
+
* The contract version that removed this operation, if one has.
|
|
434
|
+
*
|
|
435
|
+
* A removed operation leaves this list, so nothing here carries the field
|
|
436
|
+
* today. When the first removal happens, `removedIn` is where the fact is
|
|
437
|
+
* recorded — how a removed operation stays visible after leaving the list
|
|
438
|
+
* is decided then, not invented in advance.
|
|
439
|
+
*/
|
|
440
|
+
removedIn?: string;
|
|
441
|
+
}
|
|
442
|
+
declare const CONTRACT_OPERATIONS: readonly ContractOperation[];
|
|
443
|
+
/**
|
|
444
|
+
* The `/_auth` surface exported into the mobile contract: enrollment, login
|
|
445
|
+
* and key rotation. These are ordinary SPFN REST routes, not canonical-JSON
|
|
446
|
+
* operations — the dev handler never serves them, and their wire rules are
|
|
447
|
+
* the `restOperations` section of the bundle, not `canonicalJson`.
|
|
448
|
+
*
|
|
449
|
+
* The three `authProfile: 'none'` operations are the unproven class: they are
|
|
450
|
+
* accepted with neither proof headers nor a session header, because they are
|
|
451
|
+
* how a client obtains a key in the first place. `auth.keys.rotate` requires
|
|
452
|
+
* an authenticated caller (a clientProofV1 proof on this surface); an
|
|
453
|
+
* unproven call to it is refused like any failed admission.
|
|
454
|
+
*/
|
|
455
|
+
declare const AUTH_SURFACE_OPERATIONS: readonly ContractOperation[];
|
|
456
|
+
/** The body is canonical JSON but not the declared request type. */
|
|
457
|
+
declare class ContractTypeError extends Error {
|
|
458
|
+
constructor();
|
|
459
|
+
}
|
|
460
|
+
interface HandshakeRequest {
|
|
461
|
+
clientId: string;
|
|
462
|
+
keyId: string;
|
|
463
|
+
nonce: string;
|
|
464
|
+
issuedAtMillis: bigint;
|
|
465
|
+
}
|
|
466
|
+
interface EchoRequest {
|
|
467
|
+
message: string;
|
|
468
|
+
sequence: bigint;
|
|
469
|
+
}
|
|
470
|
+
interface ListItemsRequest {
|
|
471
|
+
limit: bigint;
|
|
472
|
+
cursor?: string;
|
|
473
|
+
}
|
|
474
|
+
interface ContractItem {
|
|
475
|
+
id: string;
|
|
476
|
+
name: string;
|
|
477
|
+
updatedAtMillis: bigint;
|
|
478
|
+
}
|
|
479
|
+
declare function decodeHandshakeRequest(value: CanonicalValue): HandshakeRequest;
|
|
480
|
+
declare function decodeEchoRequest(value: CanonicalValue): EchoRequest;
|
|
481
|
+
declare function decodeListItemsRequest(value: CanonicalValue): ListItemsRequest;
|
|
482
|
+
declare function encodeHandshakeResponse(sessionId: string, expiresAtMillis: bigint): CanonicalValue;
|
|
483
|
+
declare function encodeEchoResponse(message: string, sequence: bigint, serverTimeMillis: bigint): CanonicalValue;
|
|
484
|
+
declare function encodeListItemsResponse(items: ContractItem[], nextCursor: string | null): CanonicalValue;
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The items `items.list` pages through — fixed and small on purpose, matching
|
|
488
|
+
* the spfn-mobile reference catalogue byte for byte so an integration test can
|
|
489
|
+
* assert exact values against either server.
|
|
490
|
+
*/
|
|
491
|
+
declare const DEV_CATALOGUE: readonly ContractItem[];
|
|
492
|
+
/** The largest `items.list` page this server will answer with. */
|
|
493
|
+
declare const DEV_MAX_LIMIT = 100n;
|
|
494
|
+
interface ClientProofDevHandlerOptions extends ClientProofStateOptions {
|
|
495
|
+
/**
|
|
496
|
+
* Token the `/control` routes require (header `x-spfn-reference-control`).
|
|
497
|
+
* Generated per construction when omitted; never logged.
|
|
498
|
+
*/
|
|
499
|
+
controlToken?: string;
|
|
500
|
+
/** Disables the `/control` surface entirely. @default true */
|
|
501
|
+
enableControl?: boolean;
|
|
502
|
+
/** One line per request: method, path, status. Nothing a request carried. */
|
|
503
|
+
log?: (line: string) => void;
|
|
504
|
+
}
|
|
505
|
+
interface ClientProofDevHandler {
|
|
506
|
+
fetch(request: Request): Promise<Response>;
|
|
507
|
+
state: ClientProofState;
|
|
508
|
+
controlToken: string;
|
|
509
|
+
}
|
|
510
|
+
declare function createClientProofDevHandler(options: ClientProofDevHandlerOptions): ClientProofDevHandler;
|
|
511
|
+
|
|
512
|
+
declare const CONTROL_PREFIX = "/control/";
|
|
513
|
+
declare const CONTROL_TOKEN_HEADER = "x-spfn-reference-control";
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Hono middleware adapter for clientProofV1 — the `requiresSession` guard for
|
|
517
|
+
* SPFN servers that mount contract operations as ordinary routes.
|
|
518
|
+
*
|
|
519
|
+
* Runs the full admission sequence over the raw request bytes and, on
|
|
520
|
+
* acceptance, tags the request `clientType: 'mobile'` (the attestation slot
|
|
521
|
+
* PROXY-BACKEND-AUTH-SPEC reserved) and exposes the parsed canonical body and
|
|
522
|
+
* credentials under the `clientProof` context key.
|
|
523
|
+
*
|
|
524
|
+
* hono is imported as types only — the middleware itself is a plain async
|
|
525
|
+
* function, so this module adds no runtime dependency.
|
|
526
|
+
*
|
|
527
|
+
* @module server/client-proof/guard
|
|
528
|
+
*/
|
|
529
|
+
|
|
530
|
+
/** What the guard leaves in the context for the route handler. */
|
|
531
|
+
interface ClientProofContext {
|
|
532
|
+
credentials: ClientProofCredentials;
|
|
533
|
+
/** The request body as a canonical value (already byte-verified). */
|
|
534
|
+
value: CanonicalValue;
|
|
535
|
+
}
|
|
536
|
+
interface ClientProofGuardOptions {
|
|
537
|
+
/**
|
|
538
|
+
* The contract path the client signed, when it differs from the mounted
|
|
539
|
+
* path (e.g. behind a stripped ingress prefix). Defaults to the request
|
|
540
|
+
* path.
|
|
541
|
+
*/
|
|
542
|
+
contractPath?: string;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* A guard for operations with `requiresSession: true`.
|
|
546
|
+
*
|
|
547
|
+
* Refusals are answered with the contract envelope and never reach the route.
|
|
548
|
+
*/
|
|
549
|
+
declare function createClientProofGuard(state: ClientProofState, options?: ClientProofGuardOptions): MiddlewareHandler;
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* One place turns a clientProofV1 refusal into a response.
|
|
553
|
+
*
|
|
554
|
+
* A proven call is answered by a generated SDK that classifies a failure by
|
|
555
|
+
* `error.code` alone and refuses a code it does not know. So a refusal must
|
|
556
|
+
* leave this server as the contract's own envelope — the canonical bytes of
|
|
557
|
+
* `{"error":{"code","message","requestId"}}` carrying one of the six refusal
|
|
558
|
+
* codes — and nothing else. Routing a refusal through the generic error
|
|
559
|
+
* handler instead puts the wrapper error class's name in `error.code`
|
|
560
|
+
* (`UnauthorizedError`), which no SDK can classify (#106).
|
|
561
|
+
*
|
|
562
|
+
* Every refusal surface (the guard, the profile middleware) builds its answer
|
|
563
|
+
* here rather than assembling one of its own, so a code path added later
|
|
564
|
+
* cannot reintroduce a body that says something else.
|
|
565
|
+
*
|
|
566
|
+
* hono is imported as types only, so this module adds no runtime dependency.
|
|
567
|
+
*
|
|
568
|
+
* @module server/client-proof/refusal-response
|
|
569
|
+
*/
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The canonical contract envelope for one refusal, with the server's contract
|
|
573
|
+
* announcement — a refused client needs the range most.
|
|
574
|
+
*/
|
|
575
|
+
declare function clientProofRefusalResponse(c: Context, refusal: ClientProofRefusal): Response;
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The version announcement, applied to every request rather than to the proven
|
|
579
|
+
* ones.
|
|
580
|
+
*
|
|
581
|
+
* Enrollment and login are the first calls a client makes and they carry no
|
|
582
|
+
* proof — there is no key to sign with yet. A check that lives inside proof
|
|
583
|
+
* admission therefore never sees the client it is meant to catch: an outdated
|
|
584
|
+
* app fails at login, before it reaches anything proven. This runs ahead of all
|
|
585
|
+
* of it.
|
|
586
|
+
*
|
|
587
|
+
* hono is imported as types only, so this module adds no runtime dependency.
|
|
588
|
+
*
|
|
589
|
+
* @module server/client-proof/version-middleware
|
|
590
|
+
*/
|
|
591
|
+
|
|
592
|
+
/** The context key the identity is left under, for a handler that wants it. */
|
|
593
|
+
declare const CLIENT_IDENTITY_CONTEXT_KEY = "clientIdentity";
|
|
594
|
+
/**
|
|
595
|
+
* Announces the server's contract version on every response and refuses a
|
|
596
|
+
* client whose own contract version this server does not serve.
|
|
597
|
+
*
|
|
598
|
+
* The announcement goes out either way. A refused client needs it most — the
|
|
599
|
+
* refusal says the two ends disagree, and the range is what says how.
|
|
600
|
+
*
|
|
601
|
+
* Mount this before authentication, not after: the point is to answer a stale
|
|
602
|
+
* client before anything else has a chance to fail confusingly.
|
|
603
|
+
*/
|
|
604
|
+
declare function createClientVersionMiddleware(): MiddlewareHandler;
|
|
605
|
+
|
|
606
|
+
export { ABSENT_BODY_SHA256, AUTH_SURFACE_OPERATIONS, type Admission, CLIENT_IDENTITY_CONTEXT_KEY, CLIENT_PROOF_CONTENT_TYPE, CLIENT_PROOF_HEADERS, CLIENT_PROOF_PROFILE, CONTRACT_OPERATIONS, CONTROL_PREFIX, CONTROL_TOKEN_HEADER, CanonicalJsonError, type CanonicalJsonErrorCode, type CanonicalObject, type CanonicalValue, type ClientProofClock, type ClientProofContext, type ClientProofCredentials, type ClientProofDevHandler, type ClientProofDevHandlerOptions, type ClientProofGuardOptions, type ClientProofInput, ClientProofRefusal, type ClientProofReplayStore, ClientProofState, type ClientProofStateOptions, type ClientProofStats, type ContractItem, type ContractOperation, ContractTypeError, DEFAULT_REPLAY_WINDOW_MILLIS, DEFAULT_SESSION_TTL_MILLIS, DEV_CATALOGUE, DEV_MAX_LIMIT, type EchoRequest, type HandshakeRequest, type ListItemsRequest, MemoryReplayLedger, MemoryReplayStore, PROOF_SIGNATURE_BYTES, PROOF_SIGNATURE_HEX_LENGTH, ProofInputError, RedisReplayStore, TestClock, admitClientProofRequest, canonicalProofInput, clientProofRefusalResponse, configureClientProofReplayStore, createClientProofDevHandler, createClientProofGuard, createClientVersionMiddleware, decodeEchoRequest, decodeHandshakeRequest, decodeListItemsRequest, encodeCanonicalJson, encodeEchoResponse, encodeHandshakeResponse, encodeListItemsResponse, getClientProofReplayStore, isCanonicalBytes, isRequestContentType, parseCanonicalJson, parseClientProofPublicKey, readCredentials, replayLedgerKey, sha256Hex, signClientProof, systemClock, verifyClientProof };
|