@spfn/auth 0.2.0-beta.84 → 0.2.0-beta.86
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/README.md +93 -2
- package/dist/{authenticate-ngWdlWWL.d.ts → authenticate-DTA7W4v2.d.ts} +4 -4
- package/dist/client-proof.d.ts +418 -0
- package/dist/client-proof.js +1264 -0
- package/dist/client-proof.js.map +1 -0
- package/dist/index.d.ts +6 -6
- package/dist/server.d.ts +70 -70
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -13,8 +13,8 @@ pnpm add @spfn/auth drizzle-orm@1.0.0-rc.4
|
|
|
13
13
|
|
|
14
14
|
## Import paths
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
`/server` and `/nextjs/*` pull in Node
|
|
16
|
+
Entry points (from `package.json` `exports`). Picking the wrong one breaks the build —
|
|
17
|
+
`/server`, `/client-proof` and `/nextjs/*` pull in Node code and must never reach the browser bundle.
|
|
18
18
|
|
|
19
19
|
```typescript
|
|
20
20
|
import { authApi, authRouteMap } from '@spfn/auth'; // isomorphic: client + route map + types/constants
|
|
@@ -25,6 +25,7 @@ import { InvalidCredentialsError } from '@spfn/auth/errors'; // error clas
|
|
|
25
25
|
import '@spfn/auth/nextjs/api'; // SERVER: auto-registers RPC interceptors (side-effect)
|
|
26
26
|
import { RequireAuth, getSession } from '@spfn/auth/nextjs/server'; // SERVER: RSC guards, session helpers, OAuth handler
|
|
27
27
|
import { OAuthCallback } from '@spfn/auth/nextjs/client'; // 'use client' OAuth callback component
|
|
28
|
+
import { createClientProofDevHandler } from '@spfn/auth/client-proof'; // SERVER: mobile clientProofV1 profile (see below)
|
|
28
29
|
```
|
|
29
30
|
|
|
30
31
|
> Database entities (`users`, `userPublicKeys`, …) and all services/repositories are exported
|
|
@@ -549,6 +550,96 @@ For short-lived authenticated handshakes (e.g. SSE) where a `Bearer` header is a
|
|
|
549
550
|
with `authApi.issueOneTimeToken`, protect the consuming route with the `oneTimeTokenAuth`
|
|
550
551
|
middleware. Call `initOneTimeTokenManager({ ttl, store })` during setup for a custom TTL/store.
|
|
551
552
|
|
|
553
|
+
## Mobile clientProofV1 (`@spfn/auth/client-proof`)
|
|
554
|
+
|
|
555
|
+
Server side of the spfn-mobile native SDK auth profile (issue #46). Implements the pinned
|
|
556
|
+
mobile contract exactly: SPFN-CANON-JSON-1 canonical JSON (custom parser/encoder — int64 via
|
|
557
|
+
BigInt, duplicate-key rejection, UTF-8 byte key order), SPFN-PROOF-INPUT-1 HMAC-SHA-256 proof
|
|
558
|
+
verification (constant-time), the contract admission order (revoked → session → expired →
|
|
559
|
+
replayed → HMAC; a nonce is spent only on admission), in-memory session issuance/expiry, and
|
|
560
|
+
the fixed-string contract error envelope (`PROOF_INVALID` · `PROOF_REPLAYED` · `PROOF_EXPIRED` ·
|
|
561
|
+
`SESSION_REVOKED` · `PROFILE_REJECTED` · `CONTRACT_UNSUPPORTED` — SDKs classify by code, never
|
|
562
|
+
HTTP status).
|
|
563
|
+
|
|
564
|
+
- Wire headers (D23, ratified): `x-spfn-auth-profile`, `x-spfn-client-id`, `x-spfn-key-id`,
|
|
565
|
+
`x-spfn-nonce`, `x-spfn-issued-at`, `x-spfn-proof`, `x-spfn-session`.
|
|
566
|
+
- A request body must be **byte-canonical** — a body that parses but re-encodes differently is
|
|
567
|
+
refused even when its proof verifies (the proof binds the received bytes).
|
|
568
|
+
- `createClientProofDevHandler(...)` — framework-free `fetch(Request) → Response` dev surface
|
|
569
|
+
with the three contract operations and the `/control` test hooks the spfn-mobile integration
|
|
570
|
+
suites drive (`examples/04-mobile-contract-dev` is the runnable wiring).
|
|
571
|
+
- `createClientProofGuard(state)` — Hono middleware for mounting `requiresSession` operations
|
|
572
|
+
on an SPFN server; tags admitted requests `clientType: 'mobile'` (the attestation slot
|
|
573
|
+
proxy-guard reserved). hono is a type-only import here.
|
|
574
|
+
- Replay ledger is module-local, NOT core's `NonceStore` — `checkAndSet` records on check,
|
|
575
|
+
which would spend a nonce on a refused request; the contract requires spending only on
|
|
576
|
+
admission.
|
|
577
|
+
- Conformance: spfn-mobile fixtures are vendored under
|
|
578
|
+
`src/server/client-proof/__tests__/fixtures/` (digest-pinned to upstream `MANIFEST.json`,
|
|
579
|
+
dev bundle sha256 `07fd8268…a433e45`) and run in the unit suite.
|
|
580
|
+
- Dev/test scope: key provisioning is injection at construction; no persistence. A production
|
|
581
|
+
key/issuance story is a separate work item.
|
|
582
|
+
|
|
583
|
+
### Usage — dev surface (mobile integration target)
|
|
584
|
+
|
|
585
|
+
The fastest path: run the packaged dev handler, which already serves the three contract
|
|
586
|
+
operations and `/control`. `examples/04-mobile-contract-dev` is exactly this, runnable.
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
import { serve } from '@hono/node-server';
|
|
590
|
+
import { createClientProofDevHandler } from '@spfn/auth/client-proof';
|
|
591
|
+
|
|
592
|
+
const handler = createClientProofDevHandler({
|
|
593
|
+
keys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_KEY! }, // keyId → HMAC key
|
|
594
|
+
sessionTtlMillis: 600_000,
|
|
595
|
+
});
|
|
596
|
+
serve({ fetch: handler.fetch, port: 8791, hostname: '127.0.0.1' });
|
|
597
|
+
// handler.controlToken — pass to the test harness for /control routes
|
|
598
|
+
// handler.state — revokeKey() / expireSessions() / stats() from code
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
### Usage — mounting on your own Hono/SPFN server
|
|
602
|
+
|
|
603
|
+
Protect `requiresSession` operations with the guard, and assemble the handshake route from
|
|
604
|
+
the exported primitives (`admitClientProofRequest` + `state.openSession`):
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
import { Hono } from 'hono';
|
|
608
|
+
import {
|
|
609
|
+
ClientProofState, createClientProofGuard, admitClientProofRequest,
|
|
610
|
+
decodeHandshakeRequest, encodeHandshakeResponse, encodeCanonicalJson,
|
|
611
|
+
ClientProofRefusal, newHexId,
|
|
612
|
+
} from '@spfn/auth/client-proof';
|
|
613
|
+
|
|
614
|
+
const state = new ClientProofState({ keys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_KEY! } });
|
|
615
|
+
const app = new Hono();
|
|
616
|
+
|
|
617
|
+
app.post('/v1/auth/client-proof/handshake', async (c) =>
|
|
618
|
+
{
|
|
619
|
+
const body = new Uint8Array(await c.req.arrayBuffer());
|
|
620
|
+
const admission = admitClientProofRequest({
|
|
621
|
+
state, headers: c.req.raw.headers, method: 'POST',
|
|
622
|
+
path: '/v1/auth/client-proof/handshake', requiresSession: false, body,
|
|
623
|
+
});
|
|
624
|
+
if (!admission.admitted)
|
|
625
|
+
{
|
|
626
|
+
return c.newResponse(admission.refusal.envelopeBytes(newHexId()).slice().buffer,
|
|
627
|
+
admission.refusal.httpStatus as 401, { 'content-type': 'application/json' });
|
|
628
|
+
}
|
|
629
|
+
const request = decodeHandshakeRequest(admission.value);
|
|
630
|
+
const opened = state.openSession(request.clientId, request.keyId);
|
|
631
|
+
return c.newResponse(
|
|
632
|
+
encodeCanonicalJson(encodeHandshakeResponse(opened.sessionId, BigInt(opened.expiresAtMillis))).slice().buffer,
|
|
633
|
+
200, { 'content-type': 'application/json' });
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Any route behind the guard sees clientType='mobile' and c.get('clientProof')
|
|
637
|
+
app.post('/v1/echo', createClientProofGuard(state), (c) => { /* handler */ });
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
Responses and errors MUST be canonical bytes with the contract envelope — build them with
|
|
641
|
+
`encodeCanonicalJson`/`ClientProofRefusal`, never `c.json()` (key order and int64 differ).
|
|
642
|
+
|
|
552
643
|
## Account Deletion & Recovery
|
|
553
644
|
|
|
554
645
|
Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
|
|
@@ -732,7 +732,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
732
732
|
id: number;
|
|
733
733
|
name: string;
|
|
734
734
|
displayName: string;
|
|
735
|
-
category: "
|
|
735
|
+
category: "custom" | "user" | "auth" | "rbac" | "system" | undefined;
|
|
736
736
|
}[];
|
|
737
737
|
userId: number;
|
|
738
738
|
publicId: string;
|
|
@@ -1029,8 +1029,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1029
1029
|
}, {}, {
|
|
1030
1030
|
roles: {
|
|
1031
1031
|
description: string | null;
|
|
1032
|
-
name: string;
|
|
1033
1032
|
id: number;
|
|
1033
|
+
name: string;
|
|
1034
1034
|
displayName: string;
|
|
1035
1035
|
isBuiltin: boolean;
|
|
1036
1036
|
isSystem: boolean;
|
|
@@ -1051,8 +1051,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1051
1051
|
}, {}, {
|
|
1052
1052
|
role: {
|
|
1053
1053
|
description: string | null;
|
|
1054
|
-
name: string;
|
|
1055
1054
|
id: number;
|
|
1055
|
+
name: string;
|
|
1056
1056
|
displayName: string;
|
|
1057
1057
|
isBuiltin: boolean;
|
|
1058
1058
|
isSystem: boolean;
|
|
@@ -1075,8 +1075,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1075
1075
|
}, {}, {
|
|
1076
1076
|
role: {
|
|
1077
1077
|
description: string | null;
|
|
1078
|
-
name: string;
|
|
1079
1078
|
id: number;
|
|
1079
|
+
name: string;
|
|
1080
1080
|
displayName: string;
|
|
1081
1081
|
isBuiltin: boolean;
|
|
1082
1082
|
isSystem: boolean;
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { MiddlewareHandler } from 'hono';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SPFN-CANON-JSON-1 — the canonical JSON form the mobile contract pins.
|
|
5
|
+
*
|
|
6
|
+
* The rules (Contracts/spfn-mobile-contract.v1.json `canonicalJson`):
|
|
7
|
+
* - object keys sorted ascending by UTF-8 byte sequence
|
|
8
|
+
* - no insignificant whitespace
|
|
9
|
+
* - numbers are signed 64-bit integers only
|
|
10
|
+
* - string escapes: `"` and `\` escaped; C0 controls use \b \f \n \r \t where
|
|
11
|
+
* defined and lowercase \u00XX otherwise; every other scalar is emitted
|
|
12
|
+
* literally as UTF-8
|
|
13
|
+
* - absent optional fields are omitted, never null
|
|
14
|
+
*
|
|
15
|
+
* JSON.parse cannot implement this: it loses int64 precision, accepts duplicate
|
|
16
|
+
* keys and (in V8) raw control characters, so both directions are hand-rolled.
|
|
17
|
+
* A proof binds the received bytes — parse-then-re-encode equality is what makes
|
|
18
|
+
* canonicity a rule a client can actually break.
|
|
19
|
+
*
|
|
20
|
+
* @module server/client-proof/canonical-json
|
|
21
|
+
*/
|
|
22
|
+
type CanonicalObject = Map<string, CanonicalValue>;
|
|
23
|
+
type CanonicalValue = null | boolean | bigint | string | CanonicalValue[] | CanonicalObject;
|
|
24
|
+
/**
|
|
25
|
+
* Parse failures carry the code the mobile conformance fixtures name
|
|
26
|
+
* (Contracts/fixtures/canonical/rejects.json), so the fixtures can assert on it.
|
|
27
|
+
*/
|
|
28
|
+
type CanonicalJsonErrorCode = 'DUPLICATE_KEY' | 'NON_INTEGER_NUMBER' | 'TRAILING_CONTENT' | 'UNEXPECTED_END' | 'INVALID_TOKEN' | 'INVALID_ESCAPE' | 'INTEGER_OUT_OF_RANGE' | 'INVALID_UTF8';
|
|
29
|
+
declare class CanonicalJsonError extends Error {
|
|
30
|
+
readonly code: CanonicalJsonErrorCode;
|
|
31
|
+
constructor(code: CanonicalJsonErrorCode);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse bytes as SPFN-CANON-JSON-1.
|
|
35
|
+
*
|
|
36
|
+
* Arbitrary whitespace and key order are accepted here — parsing alone proves
|
|
37
|
+
* nothing about canonicity. Callers that must enforce it re-encode the result
|
|
38
|
+
* and compare bytes (see `isCanonicalBytes`).
|
|
39
|
+
*/
|
|
40
|
+
declare function parseCanonicalJson(bytes: Uint8Array): CanonicalValue;
|
|
41
|
+
/** True when `bytes` are exactly the canonical encoding of the value they parse to. */
|
|
42
|
+
declare function isCanonicalBytes(bytes: Uint8Array, value: CanonicalValue): boolean;
|
|
43
|
+
/** Encode a value as SPFN-CANON-JSON-1 bytes. */
|
|
44
|
+
declare function encodeCanonicalJson(value: CanonicalValue): Uint8Array;
|
|
45
|
+
|
|
46
|
+
/** The only auth profile this module implements. */
|
|
47
|
+
declare const CLIENT_PROOF_PROFILE = "clientProofV1";
|
|
48
|
+
/** `bodySha256` when an operation carries no body: 64 zero characters. */
|
|
49
|
+
declare const ABSENT_BODY_SHA256: string;
|
|
50
|
+
/** The contract's `clientProofV1.replayWindowMillis`. */
|
|
51
|
+
declare const DEFAULT_REPLAY_WINDOW_MILLIS = 300000;
|
|
52
|
+
interface ClientProofInput {
|
|
53
|
+
method: string;
|
|
54
|
+
path: string;
|
|
55
|
+
clientId: string;
|
|
56
|
+
keyId: string;
|
|
57
|
+
nonce: string;
|
|
58
|
+
issuedAtMillis: bigint;
|
|
59
|
+
bodySha256: string;
|
|
60
|
+
}
|
|
61
|
+
/** A C0 control character appeared in a proof field. */
|
|
62
|
+
declare class ProofInputError extends Error {
|
|
63
|
+
constructor();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The canonical proof-input string the MAC is taken over.
|
|
67
|
+
*
|
|
68
|
+
* @throws ProofInputError when any field contains a C0 control character.
|
|
69
|
+
*/
|
|
70
|
+
declare function canonicalProofInput(input: ClientProofInput): string;
|
|
71
|
+
/** The base16-lower HMAC-SHA-256 proof for `input` under `key`. */
|
|
72
|
+
declare function computeClientProof(input: ClientProofInput, key: Uint8Array): string;
|
|
73
|
+
/** Lowercase base16 SHA-256 of `bytes`. */
|
|
74
|
+
declare function sha256Hex(bytes: Uint8Array): string;
|
|
75
|
+
/**
|
|
76
|
+
* Constant-time comparison of two proof strings.
|
|
77
|
+
*
|
|
78
|
+
* Length is checked first (its leak reveals nothing — the expected length is
|
|
79
|
+
* public), then the bytes are compared with `timingSafeEqual`.
|
|
80
|
+
*/
|
|
81
|
+
declare function constantTimeEqualsProof(expected: string, presented: string): boolean;
|
|
82
|
+
|
|
83
|
+
/** The six wire codes. The SDKs classify by code, never HTTP status. */
|
|
84
|
+
type ClientProofErrorCode = 'PROOF_INVALID' | 'PROOF_REPLAYED' | 'PROOF_EXPIRED' | 'SESSION_REVOKED' | 'PROFILE_REJECTED' | 'CONTRACT_UNSUPPORTED';
|
|
85
|
+
/** 128 random bits as lowercase base16 — request ids and control tokens. */
|
|
86
|
+
declare function newHexId(): string;
|
|
87
|
+
declare class ClientProofRefusal {
|
|
88
|
+
readonly code: ClientProofErrorCode;
|
|
89
|
+
readonly message: string;
|
|
90
|
+
constructor(code: ClientProofErrorCode, message: string);
|
|
91
|
+
get httpStatus(): number;
|
|
92
|
+
/** The canonical bytes of `{"error":{"code":…,"message":…,"requestId":…}}`. */
|
|
93
|
+
envelopeBytes(requestId: string): Uint8Array;
|
|
94
|
+
/** Nothing request-derived reaches a log through this. */
|
|
95
|
+
toString(): string;
|
|
96
|
+
static unroutable(): ClientProofRefusal;
|
|
97
|
+
static malformedHeaders(): ClientProofRefusal;
|
|
98
|
+
static missingContentType(): ClientProofRefusal;
|
|
99
|
+
static bodyTooLarge(): ClientProofRefusal;
|
|
100
|
+
/**
|
|
101
|
+
* The body parsed but its bytes are not the canonical form of what it
|
|
102
|
+
* parsed to. Not PROOF_INVALID even though it is discovered next to the
|
|
103
|
+
* proof: the proof over these bytes verifies perfectly well, and an
|
|
104
|
+
* auth-family answer would tell the client to re-handshake and send the
|
|
105
|
+
* same non-canonical bytes again.
|
|
106
|
+
*/
|
|
107
|
+
static bodyNotCanonical(): ClientProofRefusal;
|
|
108
|
+
static bodyNotTheDeclaredType(): ClientProofRefusal;
|
|
109
|
+
static sessionHeaderMisplaced(): ClientProofRefusal;
|
|
110
|
+
static unprocessable(): ClientProofRefusal;
|
|
111
|
+
static profileRejected(): ClientProofRefusal;
|
|
112
|
+
static sessionRevoked(): ClientProofRefusal;
|
|
113
|
+
static proofExpired(): ClientProofRefusal;
|
|
114
|
+
static proofReplayed(): ClientProofRefusal;
|
|
115
|
+
static proofInvalid(): ClientProofRefusal;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Everything a clientProofV1 server remembers between requests: issued
|
|
120
|
+
* sessions, the replay ledger, revoked keys and the key directory.
|
|
121
|
+
*
|
|
122
|
+
* The admission order is the contract's, not this file's invention
|
|
123
|
+
* (`clientProofV1.revocationRule` + the replay fixtures):
|
|
124
|
+
*
|
|
125
|
+
* 1. revoked keyId / invalid session → SESSION_REVOKED — before proof
|
|
126
|
+
* verification, so revocation stays distinguishable from a bad proof;
|
|
127
|
+
* 2. issuedAtMillis outside the replay window (0 <= age <= window) → PROOF_EXPIRED;
|
|
128
|
+
* 3. a repeated (clientId, nonce) pair inside the window → PROOF_REPLAYED;
|
|
129
|
+
* 4. only then HMAC verification → PROOF_INVALID on mismatch.
|
|
130
|
+
*
|
|
131
|
+
* A nonce is recorded as spent only on admission: a request refused for any
|
|
132
|
+
* earlier reason has not spent anything, so a client that fixes the reason and
|
|
133
|
+
* retries with the same nonce is not punished twice for one mistake. This is
|
|
134
|
+
* why core's `NonceStore.checkAndSet` (which records on check) is not reused
|
|
135
|
+
* here — its semantics would spend a nonce on a refused request.
|
|
136
|
+
*
|
|
137
|
+
* `admit` is synchronous, so on Node's single thread the whole sequence is
|
|
138
|
+
* atomic: two requests presenting the same nonce cannot interleave inside it.
|
|
139
|
+
*
|
|
140
|
+
* @module server/client-proof/state
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
/** Millisecond clock. Injectable so expiry paths are testable without waiting. */
|
|
144
|
+
interface ClientProofClock {
|
|
145
|
+
nowMillis(): number;
|
|
146
|
+
}
|
|
147
|
+
declare function systemClock(): ClientProofClock;
|
|
148
|
+
/** A clock a test (or the dev control surface) can move forward. */
|
|
149
|
+
declare class TestClock implements ClientProofClock {
|
|
150
|
+
private millis;
|
|
151
|
+
constructor(millis: number);
|
|
152
|
+
nowMillis(): number;
|
|
153
|
+
advance(byMillis: number): void;
|
|
154
|
+
}
|
|
155
|
+
/** What `stats()` reports. Counters only; nothing a request carried. */
|
|
156
|
+
interface ClientProofStats {
|
|
157
|
+
requestCount: number;
|
|
158
|
+
handshakeCount: number;
|
|
159
|
+
echoCount: number;
|
|
160
|
+
itemsListCount: number;
|
|
161
|
+
refusalCount: number;
|
|
162
|
+
liveSessionCount: number;
|
|
163
|
+
spentNonceCount: number;
|
|
164
|
+
}
|
|
165
|
+
interface ClientProofStateOptions {
|
|
166
|
+
/**
|
|
167
|
+
* keyId → HMAC key. A string is taken as UTF-8 bytes. Dev provisioning is
|
|
168
|
+
* injection at construction; any issuance flow works as long as
|
|
169
|
+
* clientId/keyId/key triples exist on both ends.
|
|
170
|
+
*/
|
|
171
|
+
keys: Record<string, string | Uint8Array>;
|
|
172
|
+
clock?: ClientProofClock;
|
|
173
|
+
/** @default 600000 */
|
|
174
|
+
sessionTtlMillis?: number;
|
|
175
|
+
/** The contract's replay window. @default 300000 */
|
|
176
|
+
replayWindowMillis?: number;
|
|
177
|
+
}
|
|
178
|
+
declare const DEFAULT_SESSION_TTL_MILLIS = 600000;
|
|
179
|
+
declare class ClientProofState {
|
|
180
|
+
readonly replayWindowMillis: number;
|
|
181
|
+
private readonly clock;
|
|
182
|
+
private readonly keys;
|
|
183
|
+
private readonly sessions;
|
|
184
|
+
/** replayKeyOf(...) → the issuedAtMillis it was spent at. */
|
|
185
|
+
private readonly spentNonces;
|
|
186
|
+
private readonly revokedKeyIds;
|
|
187
|
+
private readonly holds;
|
|
188
|
+
private readonly initialSessionTtlMillis;
|
|
189
|
+
private sessionTtlMillis;
|
|
190
|
+
private requestCount;
|
|
191
|
+
private handshakeCount;
|
|
192
|
+
private echoCount;
|
|
193
|
+
private itemsListCount;
|
|
194
|
+
private refusalCount;
|
|
195
|
+
constructor(options: ClientProofStateOptions);
|
|
196
|
+
/**
|
|
197
|
+
* Runs the contract's checks in the contract's order and returns the
|
|
198
|
+
* refusal, or null when the request is admitted (spending its nonce).
|
|
199
|
+
*/
|
|
200
|
+
admit(args: {
|
|
201
|
+
clientId: string;
|
|
202
|
+
keyId: string;
|
|
203
|
+
presentedSessionId: string | null;
|
|
204
|
+
requiresSession: boolean;
|
|
205
|
+
proofInput: ClientProofInput;
|
|
206
|
+
presentedProof: string;
|
|
207
|
+
}): ClientProofRefusal | null;
|
|
208
|
+
/** Opens a session and returns its id and the expiry the server advertises. */
|
|
209
|
+
openSession(clientId: string, keyId: string): {
|
|
210
|
+
sessionId: string;
|
|
211
|
+
expiresAtMillis: number;
|
|
212
|
+
};
|
|
213
|
+
/** Test hook: installs a session with a chosen id (wire-fixture replays). */
|
|
214
|
+
seedSession(sessionId: string, clientId: string, keyId: string, expiresAtMillis: number): void;
|
|
215
|
+
/** Drops every session, as a restart would. Advertised expiries stay told. */
|
|
216
|
+
expireSessions(): void;
|
|
217
|
+
/** Revokes a key and drops the sessions it opened. */
|
|
218
|
+
revokeKey(keyId: string): void;
|
|
219
|
+
setSessionTtlMillis(millis: number): void;
|
|
220
|
+
/** Returns the state to how it started, counters included. */
|
|
221
|
+
reset(): void;
|
|
222
|
+
/** Makes the next `count` requests to `path` wait `millis` before processing. */
|
|
223
|
+
holdPath(path: string, millis: number, count: number): void;
|
|
224
|
+
/** Consumes one configured delay for `path`; returns how long to wait, or 0. */
|
|
225
|
+
takeHoldMillis(path: string): number;
|
|
226
|
+
recordRequest(): void;
|
|
227
|
+
recordOperation(operationId: string): void;
|
|
228
|
+
recordRefusal(): void;
|
|
229
|
+
stats(): ClientProofStats;
|
|
230
|
+
nowMillis(): number;
|
|
231
|
+
/** The clock, exposed for the dev control surface's advance-clock route. */
|
|
232
|
+
get clockRef(): ClientProofClock;
|
|
233
|
+
/**
|
|
234
|
+
* Drops what can no longer affect an answer. The nonce predicate is the
|
|
235
|
+
* exact negation of the window check in `admit`: an entry is dropped only
|
|
236
|
+
* once a proof carrying that issuedAtMillis would be refused as expired
|
|
237
|
+
* anyway. Dropping one moment earlier would let a nonce inside the window
|
|
238
|
+
* be spent twice.
|
|
239
|
+
*/
|
|
240
|
+
private prune;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The checks between a clientProofV1 request arriving and being applied.
|
|
245
|
+
*
|
|
246
|
+
* Shape first, then the profile allowlist, then the proof. That order is
|
|
247
|
+
* forced: none of the proof checks can run until the fields they read are
|
|
248
|
+
* known to be present and the body is known to be the bytes the digest is
|
|
249
|
+
* supposed to cover. The order *inside* the proof checks is the contract's and
|
|
250
|
+
* lives in `ClientProofState.admit`.
|
|
251
|
+
*
|
|
252
|
+
* @module server/client-proof/admission
|
|
253
|
+
*/
|
|
254
|
+
|
|
255
|
+
/** D23 wire-header names, ratified as proposed by the mobile dev bundle. */
|
|
256
|
+
declare const CLIENT_PROOF_HEADERS: {
|
|
257
|
+
readonly profile: "x-spfn-auth-profile";
|
|
258
|
+
readonly clientId: "x-spfn-client-id";
|
|
259
|
+
readonly keyId: "x-spfn-key-id";
|
|
260
|
+
readonly nonce: "x-spfn-nonce";
|
|
261
|
+
readonly issuedAtMillis: "x-spfn-issued-at";
|
|
262
|
+
readonly proof: "x-spfn-proof";
|
|
263
|
+
readonly session: "x-spfn-session";
|
|
264
|
+
};
|
|
265
|
+
declare const CLIENT_PROOF_CONTENT_TYPE = "application/json";
|
|
266
|
+
/** The contract header fields one request presented. */
|
|
267
|
+
interface ClientProofCredentials {
|
|
268
|
+
profile: string;
|
|
269
|
+
clientId: string;
|
|
270
|
+
keyId: string;
|
|
271
|
+
nonce: string;
|
|
272
|
+
issuedAtMillis: bigint;
|
|
273
|
+
proof: string;
|
|
274
|
+
sessionId: string | null;
|
|
275
|
+
}
|
|
276
|
+
type Admission = {
|
|
277
|
+
admitted: false;
|
|
278
|
+
refusal: ClientProofRefusal;
|
|
279
|
+
} | {
|
|
280
|
+
admitted: true;
|
|
281
|
+
value: CanonicalValue;
|
|
282
|
+
credentials: ClientProofCredentials;
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* Runs every check for one operation over already-read body bytes.
|
|
286
|
+
*
|
|
287
|
+
* `path` must be the operation's contract path (what the client signed), not a
|
|
288
|
+
* proxied or rewritten one.
|
|
289
|
+
*/
|
|
290
|
+
declare function admitClientProofRequest(args: {
|
|
291
|
+
state: ClientProofState;
|
|
292
|
+
headers: Headers;
|
|
293
|
+
method: string;
|
|
294
|
+
path: string;
|
|
295
|
+
requiresSession: boolean;
|
|
296
|
+
body: Uint8Array;
|
|
297
|
+
}): Admission;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The mobile dev-contract types and operations, decoded from / encoded to
|
|
301
|
+
* canonical values. Strict on purpose: a missing required field, a wrong type
|
|
302
|
+
* or an unknown field is "not the request type this operation declares".
|
|
303
|
+
*
|
|
304
|
+
* This module is the source of truth for `operations`. The exported contract
|
|
305
|
+
* bundle (`contracts/mobile/spfn-mobile-contract.v1.json`) is generated from it
|
|
306
|
+
* by `contract-bundle.ts`; spfn-mobile consumes that export rather than the
|
|
307
|
+
* other way round.
|
|
308
|
+
*
|
|
309
|
+
* @module server/client-proof/contract-types
|
|
310
|
+
*/
|
|
311
|
+
|
|
312
|
+
interface ContractOperation {
|
|
313
|
+
id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list';
|
|
314
|
+
method: 'POST';
|
|
315
|
+
path: string;
|
|
316
|
+
authProfile: 'clientProofV1';
|
|
317
|
+
requiresSession: boolean;
|
|
318
|
+
requestType: string;
|
|
319
|
+
responseType: string;
|
|
320
|
+
summary: string;
|
|
321
|
+
}
|
|
322
|
+
declare const CONTRACT_OPERATIONS: readonly ContractOperation[];
|
|
323
|
+
/** The body is canonical JSON but not the declared request type. */
|
|
324
|
+
declare class ContractTypeError extends Error {
|
|
325
|
+
constructor();
|
|
326
|
+
}
|
|
327
|
+
interface HandshakeRequest {
|
|
328
|
+
clientId: string;
|
|
329
|
+
keyId: string;
|
|
330
|
+
nonce: string;
|
|
331
|
+
issuedAtMillis: bigint;
|
|
332
|
+
}
|
|
333
|
+
interface EchoRequest {
|
|
334
|
+
message: string;
|
|
335
|
+
sequence: bigint;
|
|
336
|
+
}
|
|
337
|
+
interface ListItemsRequest {
|
|
338
|
+
limit: bigint;
|
|
339
|
+
cursor?: string;
|
|
340
|
+
}
|
|
341
|
+
interface ContractItem {
|
|
342
|
+
id: string;
|
|
343
|
+
name: string;
|
|
344
|
+
updatedAtMillis: bigint;
|
|
345
|
+
}
|
|
346
|
+
declare function decodeHandshakeRequest(value: CanonicalValue): HandshakeRequest;
|
|
347
|
+
declare function decodeEchoRequest(value: CanonicalValue): EchoRequest;
|
|
348
|
+
declare function decodeListItemsRequest(value: CanonicalValue): ListItemsRequest;
|
|
349
|
+
declare function encodeHandshakeResponse(sessionId: string, expiresAtMillis: bigint): CanonicalValue;
|
|
350
|
+
declare function encodeEchoResponse(message: string, sequence: bigint, serverTimeMillis: bigint): CanonicalValue;
|
|
351
|
+
declare function encodeListItemsResponse(items: ContractItem[], nextCursor: string | null): CanonicalValue;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The items `items.list` pages through — fixed and small on purpose, matching
|
|
355
|
+
* the spfn-mobile reference catalogue byte for byte so an integration test can
|
|
356
|
+
* assert exact values against either server.
|
|
357
|
+
*/
|
|
358
|
+
declare const DEV_CATALOGUE: readonly ContractItem[];
|
|
359
|
+
/** The largest `items.list` page this server will answer with. */
|
|
360
|
+
declare const DEV_MAX_LIMIT = 100n;
|
|
361
|
+
interface ClientProofDevHandlerOptions extends ClientProofStateOptions {
|
|
362
|
+
/**
|
|
363
|
+
* Token the `/control` routes require (header `x-spfn-reference-control`).
|
|
364
|
+
* Generated per construction when omitted; never logged.
|
|
365
|
+
*/
|
|
366
|
+
controlToken?: string;
|
|
367
|
+
/** Disables the `/control` surface entirely. @default true */
|
|
368
|
+
enableControl?: boolean;
|
|
369
|
+
/** One line per request: method, path, status. Nothing a request carried. */
|
|
370
|
+
log?: (line: string) => void;
|
|
371
|
+
}
|
|
372
|
+
interface ClientProofDevHandler {
|
|
373
|
+
fetch(request: Request): Promise<Response>;
|
|
374
|
+
state: ClientProofState;
|
|
375
|
+
controlToken: string;
|
|
376
|
+
}
|
|
377
|
+
declare function createClientProofDevHandler(options: ClientProofDevHandlerOptions): ClientProofDevHandler;
|
|
378
|
+
|
|
379
|
+
declare const CONTROL_PREFIX = "/control/";
|
|
380
|
+
declare const CONTROL_TOKEN_HEADER = "x-spfn-reference-control";
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Hono middleware adapter for clientProofV1 — the `requiresSession` guard for
|
|
384
|
+
* SPFN servers that mount contract operations as ordinary routes.
|
|
385
|
+
*
|
|
386
|
+
* Runs the full admission sequence over the raw request bytes and, on
|
|
387
|
+
* acceptance, tags the request `clientType: 'mobile'` (the attestation slot
|
|
388
|
+
* PROXY-BACKEND-AUTH-SPEC reserved) and exposes the parsed canonical body and
|
|
389
|
+
* credentials under the `clientProof` context key.
|
|
390
|
+
*
|
|
391
|
+
* hono is imported as types only — the middleware itself is a plain async
|
|
392
|
+
* function, so this module adds no runtime dependency.
|
|
393
|
+
*
|
|
394
|
+
* @module server/client-proof/guard
|
|
395
|
+
*/
|
|
396
|
+
|
|
397
|
+
/** What the guard leaves in the context for the route handler. */
|
|
398
|
+
interface ClientProofContext {
|
|
399
|
+
credentials: ClientProofCredentials;
|
|
400
|
+
/** The request body as a canonical value (already byte-verified). */
|
|
401
|
+
value: CanonicalValue;
|
|
402
|
+
}
|
|
403
|
+
interface ClientProofGuardOptions {
|
|
404
|
+
/**
|
|
405
|
+
* The contract path the client signed, when it differs from the mounted
|
|
406
|
+
* path (e.g. behind a stripped ingress prefix). Defaults to the request
|
|
407
|
+
* path.
|
|
408
|
+
*/
|
|
409
|
+
contractPath?: string;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* A guard for operations with `requiresSession: true`.
|
|
413
|
+
*
|
|
414
|
+
* Refusals are answered with the contract envelope and never reach the route.
|
|
415
|
+
*/
|
|
416
|
+
declare function createClientProofGuard(state: ClientProofState, options?: ClientProofGuardOptions): MiddlewareHandler;
|
|
417
|
+
|
|
418
|
+
export { ABSENT_BODY_SHA256, type Admission, 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 ClientProofErrorCode, type ClientProofGuardOptions, type ClientProofInput, ClientProofRefusal, 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, ProofInputError, TestClock, admitClientProofRequest, canonicalProofInput, computeClientProof, constantTimeEqualsProof, createClientProofDevHandler, createClientProofGuard, decodeEchoRequest, decodeHandshakeRequest, decodeListItemsRequest, encodeCanonicalJson, encodeEchoResponse, encodeHandshakeResponse, encodeListItemsResponse, isCanonicalBytes, newHexId, parseCanonicalJson, sha256Hex, systemClock };
|