@spfn/auth 0.2.0-beta.86 → 0.2.0-beta.87
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 +14 -9
- package/dist/{authenticate-DTA7W4v2.d.ts → authenticate-C7w-pLOR.d.ts} +60 -5
- package/dist/client-proof.d.ts +185 -46
- package/dist/client-proof.js +223 -34
- package/dist/client-proof.js.map +1 -1
- package/dist/index.d.ts +7 -6
- package/dist/server.d.ts +91 -70
- package/dist/server.js +878 -69
- package/dist/server.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -552,11 +552,14 @@ middleware. Call `initOneTimeTokenManager({ ttl, store })` during setup for a cu
|
|
|
552
552
|
|
|
553
553
|
## Mobile clientProofV1 (`@spfn/auth/client-proof`)
|
|
554
554
|
|
|
555
|
-
Server side of the spfn-mobile native SDK auth profile (issue #46
|
|
556
|
-
mobile contract exactly: SPFN-CANON-JSON-1 canonical
|
|
557
|
-
BigInt, duplicate-key rejection, UTF-8 byte key
|
|
558
|
-
|
|
559
|
-
|
|
555
|
+
Server side of the spfn-mobile native SDK auth profile (issue #46; asymmetric revision in
|
|
556
|
+
contract 0.2.0). Implements the pinned mobile contract exactly: SPFN-CANON-JSON-1 canonical
|
|
557
|
+
JSON (custom parser/encoder — int64 via BigInt, duplicate-key rejection, UTF-8 byte key
|
|
558
|
+
order), SPFN-PROOF-INPUT-1 proof assembly with ECDSA P-256 + SHA-256 signature verification
|
|
559
|
+
(wire form: raw `r‖s`, 64 bytes, base16-lower; DER is rejected, low-S is not required — the
|
|
560
|
+
nonce + replay window own uniqueness), the contract admission order (revoked → session →
|
|
561
|
+
expired → replayed → signature; a nonce is spent only on admission), in-memory session
|
|
562
|
+
issuance/expiry, and
|
|
560
563
|
the fixed-string contract error envelope (`PROOF_INVALID` · `PROOF_REPLAYED` · `PROOF_EXPIRED` ·
|
|
561
564
|
`SESSION_REVOKED` · `PROFILE_REJECTED` · `CONTRACT_UNSUPPORTED` — SDKs classify by code, never
|
|
562
565
|
HTTP status).
|
|
@@ -577,8 +580,9 @@ HTTP status).
|
|
|
577
580
|
- Conformance: spfn-mobile fixtures are vendored under
|
|
578
581
|
`src/server/client-proof/__tests__/fixtures/` (digest-pinned to upstream `MANIFEST.json`,
|
|
579
582
|
dev bundle sha256 `07fd8268…a433e45`) and run in the unit suite.
|
|
580
|
-
- Dev/test scope:
|
|
581
|
-
key
|
|
583
|
+
- Dev/test scope: public keys (SPKI DER base64, keyed by `x-spfn-key-id`) are registered at
|
|
584
|
+
construction or through the `/control/register-key` hook; the private half never reaches
|
|
585
|
+
the server. No persistence — a production enrollment/rotation story is phase 2.
|
|
582
586
|
|
|
583
587
|
### Usage — dev surface (mobile integration target)
|
|
584
588
|
|
|
@@ -590,7 +594,8 @@ import { serve } from '@hono/node-server';
|
|
|
590
594
|
import { createClientProofDevHandler } from '@spfn/auth/client-proof';
|
|
591
595
|
|
|
592
596
|
const handler = createClientProofDevHandler({
|
|
593
|
-
|
|
597
|
+
// keyId → registered public key (SPKI DER base64); the private key stays on the client
|
|
598
|
+
publicKeys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_PUBLIC_KEY! },
|
|
594
599
|
sessionTtlMillis: 600_000,
|
|
595
600
|
});
|
|
596
601
|
serve({ fetch: handler.fetch, port: 8791, hostname: '127.0.0.1' });
|
|
@@ -611,7 +616,7 @@ import {
|
|
|
611
616
|
ClientProofRefusal, newHexId,
|
|
612
617
|
} from '@spfn/auth/client-proof';
|
|
613
618
|
|
|
614
|
-
const state = new ClientProofState({
|
|
619
|
+
const state = new ClientProofState({ publicKeys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_PUBLIC_KEY! } });
|
|
615
620
|
const app = new Hono();
|
|
616
621
|
|
|
617
622
|
app.post('/v1/auth/client-proof/handshake', async (c) =>
|
|
@@ -2,6 +2,7 @@ import * as _spfn_core_route from '@spfn/core/route';
|
|
|
2
2
|
import { K as KeyAlgorithmType, e as SocialProvider } from './types-1BMx0OX1.js';
|
|
3
3
|
import * as _sinclair_typebox from '@sinclair/typebox';
|
|
4
4
|
import { Static } from '@sinclair/typebox';
|
|
5
|
+
import { Context } from 'hono';
|
|
5
6
|
import { User } from '@spfn/auth/server';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -732,7 +733,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
732
733
|
id: number;
|
|
733
734
|
name: string;
|
|
734
735
|
displayName: string;
|
|
735
|
-
category: "
|
|
736
|
+
category: "auth" | "custom" | "user" | "rbac" | "system" | undefined;
|
|
736
737
|
}[];
|
|
737
738
|
userId: number;
|
|
738
739
|
publicId: string;
|
|
@@ -1029,8 +1030,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1029
1030
|
}, {}, {
|
|
1030
1031
|
roles: {
|
|
1031
1032
|
description: string | null;
|
|
1032
|
-
id: number;
|
|
1033
1033
|
name: string;
|
|
1034
|
+
id: number;
|
|
1034
1035
|
displayName: string;
|
|
1035
1036
|
isBuiltin: boolean;
|
|
1036
1037
|
isSystem: boolean;
|
|
@@ -1051,8 +1052,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1051
1052
|
}, {}, {
|
|
1052
1053
|
role: {
|
|
1053
1054
|
description: string | null;
|
|
1054
|
-
id: number;
|
|
1055
1055
|
name: string;
|
|
1056
|
+
id: number;
|
|
1056
1057
|
displayName: string;
|
|
1057
1058
|
isBuiltin: boolean;
|
|
1058
1059
|
isSystem: boolean;
|
|
@@ -1075,8 +1076,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1075
1076
|
}, {}, {
|
|
1076
1077
|
role: {
|
|
1077
1078
|
description: string | null;
|
|
1078
|
-
id: number;
|
|
1079
1079
|
name: string;
|
|
1080
|
+
id: number;
|
|
1080
1081
|
displayName: string;
|
|
1081
1082
|
isBuiltin: boolean;
|
|
1082
1083
|
isSystem: boolean;
|
|
@@ -1104,13 +1105,67 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1104
1105
|
}>;
|
|
1105
1106
|
}>;
|
|
1106
1107
|
|
|
1108
|
+
/**
|
|
1109
|
+
* The auth-profile registry the authenticate middleware dispatches on.
|
|
1110
|
+
*
|
|
1111
|
+
* A request that names `x-spfn-auth-profile` is answered by the verifier
|
|
1112
|
+
* registered for that profile — an O(1) map lookup, never a per-profile if
|
|
1113
|
+
* chain in the middleware body. A request that names no profile falls through
|
|
1114
|
+
* to the existing Bearer path untouched.
|
|
1115
|
+
*
|
|
1116
|
+
* Every verifier converges on the same `AuthContext` the Bearer path sets, so
|
|
1117
|
+
* downstream permission/tenant code consumes one principal shape and never
|
|
1118
|
+
* branches on how it was authenticated.
|
|
1119
|
+
*
|
|
1120
|
+
* The clientProofV1 verifier reuses the phase-1 admission pieces (header
|
|
1121
|
+
* shape, canonical body, proof-input assembly, ECDSA verification) with two
|
|
1122
|
+
* production substitutions: the key directory is `user_public_keys` via
|
|
1123
|
+
* `keysRepository`, and the replay ledger is the pluggable store from
|
|
1124
|
+
* `client-proof/replay-store` (memory default, Redis opt-in). The admission
|
|
1125
|
+
* order is the contract's — revocation → session → expiry → replay → proof —
|
|
1126
|
+
* and the non-disclosure rule holds: an unregistered keyId shares
|
|
1127
|
+
* PROOF_INVALID with a failed signature, while a revoked or expired key
|
|
1128
|
+
* answers SESSION_REVOKED before the proof is ever examined.
|
|
1129
|
+
*
|
|
1130
|
+
* @module server/middleware/auth-profiles
|
|
1131
|
+
*/
|
|
1132
|
+
|
|
1133
|
+
/** What a verified request leaves in the context — one shape for every scheme. */
|
|
1107
1134
|
interface AuthContext {
|
|
1108
1135
|
user: User;
|
|
1109
1136
|
userId: string;
|
|
1110
1137
|
keyId: string;
|
|
1111
1138
|
role: string | null;
|
|
1112
1139
|
locale: string;
|
|
1140
|
+
/** How the principal was authenticated. Informational — downstream code never branches on it. */
|
|
1141
|
+
scheme: 'bearer' | 'clientProofV1' | 'oneTimeToken';
|
|
1113
1142
|
}
|
|
1143
|
+
/** A profile's verifier: admits the request and returns the principal, or throws. */
|
|
1144
|
+
interface AuthProfileVerifier {
|
|
1145
|
+
verify(c: Context): Promise<AuthContext>;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Routes a request to its profile verifier.
|
|
1149
|
+
*
|
|
1150
|
+
* - no profile header → null: the caller continues on the Bearer path;
|
|
1151
|
+
* - profile header + Authorization header → rejected (mixing prohibited);
|
|
1152
|
+
* - unknown profile value → rejected (unknownProfilePolicy: reject).
|
|
1153
|
+
*
|
|
1154
|
+
* Shared by authenticate and optionalAuth so "presented but invalid" refuses
|
|
1155
|
+
* identically on both — only the "presented nothing" outcome differs.
|
|
1156
|
+
*/
|
|
1157
|
+
declare function selectAuthProfile(c: Context): AuthProfileVerifier | null;
|
|
1158
|
+
/**
|
|
1159
|
+
* Loads the user for an authenticated key and applies the account-status
|
|
1160
|
+
* rules. One implementation for every scheme: the Bearer path and the profile
|
|
1161
|
+
* verifiers call this, so a status added here gates both identically.
|
|
1162
|
+
*/
|
|
1163
|
+
declare function resolveAuthenticatedUser(userId: number): Promise<{
|
|
1164
|
+
user: User;
|
|
1165
|
+
role: string | null;
|
|
1166
|
+
locale: string;
|
|
1167
|
+
}>;
|
|
1168
|
+
|
|
1114
1169
|
declare module 'hono' {
|
|
1115
1170
|
interface ContextVariableMap {
|
|
1116
1171
|
auth: AuthContext;
|
|
@@ -1176,4 +1231,4 @@ declare const authenticate: _spfn_core_route.NamedMiddleware<"auth">;
|
|
|
1176
1231
|
*/
|
|
1177
1232
|
declare const optionalAuth: _spfn_core_route.NamedMiddleware<"optionalAuth">;
|
|
1178
1233
|
|
|
1179
|
-
export { type OAuthCallbackResult as $, type AuthSession as A, rotateKeyService as B, type ChangePasswordParams as C, revokeKeyService as D, type RegisterPublicKeyParams as E, type RotateKeyParams as F, type RevokeKeyParams as G, issueOneTimeTokenService as H, type IssueOneTimeTokenResult as I, verifyOneTimeTokenService as J, oauthStartService as K, type LoginResult as L, oauthCallbackService as M, buildOAuthErrorUrl as N, type OAuthStartResult as O, type PermissionConfig as P, isOAuthProviderEnabled as Q, type RoleConfig as R, type SendVerificationCodeResult as S, requireEnabledProvider as T, type UserProfile as U, type VerificationTargetType as V, getEnabledOAuthProviders as W, getGoogleAccessToken as X, oauthUnlinkNotifyService as Y, type OAuthStartParams as Z, type OAuthCallbackParams as _, type RegisterResult as a, type UnlinkNotifyResult as a0, oauthNativeService as a1, type OAuthNativeParams as a2,
|
|
1234
|
+
export { type OAuthCallbackResult as $, type AuthSession as A, rotateKeyService as B, type ChangePasswordParams as C, revokeKeyService as D, type RegisterPublicKeyParams as E, type RotateKeyParams as F, type RevokeKeyParams as G, issueOneTimeTokenService as H, type IssueOneTimeTokenResult as I, verifyOneTimeTokenService as J, oauthStartService as K, type LoginResult as L, oauthCallbackService as M, buildOAuthErrorUrl as N, type OAuthStartResult as O, type PermissionConfig as P, isOAuthProviderEnabled as Q, type RoleConfig as R, type SendVerificationCodeResult as S, requireEnabledProvider as T, type UserProfile as U, type VerificationTargetType as V, getEnabledOAuthProviders as W, getGoogleAccessToken as X, oauthUnlinkNotifyService as Y, type OAuthStartParams as Z, type OAuthCallbackParams as _, type RegisterResult as a, type UnlinkNotifyResult as a0, oauthNativeService as a1, type OAuthNativeParams as a2, selectAuthProfile as a3, resolveAuthenticatedUser as a4, type AuthProfileVerifier as a5, authenticate as a6, optionalAuth as a7, EmailSchema as a8, PhoneSchema as a9, PasswordSchema as aa, TargetTypeSchema as ab, VerificationPurposeSchema as ac, type NormalizedIdentity as ad, type OAuthTokens as ae, type NativeVerifyOptions as af, type OAuthCodeExchangeOptions as ag, type UnlinkNotifyRequest as ah, type UnlinkNotification as ai, UnlinkNotifyRejection as aj, registerOAuthProvider as ak, getOAuthProvider as al, getRegisteredProviders as am, type RotateKeyResult as b, type OAuthNativeResult as c, type ProfileInfo as d, type VerificationPurpose as e, VERIFICATION_TARGET_TYPES as f, VERIFICATION_PURPOSES as g, PERMISSION_CATEGORIES as h, type PermissionCategory as i, type AuthInitOptions as j, type OAuthProvider as k, type AuthContext as l, mainAuthRouter as m, loginService as n, logoutService as o, changePasswordService as p, type RegisterParams as q, registerService as r, type LoginParams as s, type LogoutParams as t, sendVerificationCodeService as u, verifyCodeService as v, type SendVerificationCodeParams as w, type VerifyCodeParams as x, type VerifyCodeResult as y, registerPublicKeyService as z };
|
package/dist/client-proof.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { KeyObject } from 'node:crypto';
|
|
1
2
|
import { MiddlewareHandler } from 'hono';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* SPFN-CANON-JSON-1 — the canonical JSON form the mobile contract pins.
|
|
5
6
|
*
|
|
6
|
-
* The rules (
|
|
7
|
+
* The rules (contracts/mobile/spfn-mobile-contract.json `canonicalJson`):
|
|
7
8
|
* - object keys sorted ascending by UTF-8 byte sequence
|
|
8
9
|
* - no insignificant whitespace
|
|
9
10
|
* - numbers are signed 64-bit integers only
|
|
@@ -43,12 +44,32 @@ declare function isCanonicalBytes(bytes: Uint8Array, value: CanonicalValue): boo
|
|
|
43
44
|
/** Encode a value as SPFN-CANON-JSON-1 bytes. */
|
|
44
45
|
declare function encodeCanonicalJson(value: CanonicalValue): Uint8Array;
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* SPFN-PROOF-INPUT-1 — proof-input assembly and verification for clientProofV1.
|
|
49
|
+
*
|
|
50
|
+
* The proof input is 8 fields joined by `\n` in fixed order: profile, method,
|
|
51
|
+
* path, clientId, keyId, nonce, issuedAtMillis, bodySha256. Any C0 control
|
|
52
|
+
* character in any field is a hard refusal (the separator would otherwise be
|
|
53
|
+
* ambiguous), never something to escape. The proof is an ECDSA P-256 signature
|
|
54
|
+
* with SHA-256 over the canonical input's UTF-8 bytes, wire-encoded as the raw
|
|
55
|
+
* `r ‖ s` 64 bytes in base16-lower (128 hex characters). DER is never accepted
|
|
56
|
+
* on the wire: a platform signer that emits DER (Java `Signature`) converts to
|
|
57
|
+
* raw before sending. Low-S normalization is not required — uniqueness is owned
|
|
58
|
+
* by the nonce and replay window, so signature malleability cannot replay.
|
|
59
|
+
*
|
|
60
|
+
* @module server/client-proof/proof
|
|
61
|
+
*/
|
|
62
|
+
|
|
46
63
|
/** The only auth profile this module implements. */
|
|
47
64
|
declare const CLIENT_PROOF_PROFILE = "clientProofV1";
|
|
48
65
|
/** `bodySha256` when an operation carries no body: 64 zero characters. */
|
|
49
66
|
declare const ABSENT_BODY_SHA256: string;
|
|
50
67
|
/** The contract's `clientProofV1.replayWindowMillis`. */
|
|
51
68
|
declare const DEFAULT_REPLAY_WINDOW_MILLIS = 300000;
|
|
69
|
+
/** Raw `r ‖ s`: two 32-byte big-endian integers, always exactly this long. */
|
|
70
|
+
declare const PROOF_SIGNATURE_BYTES = 64;
|
|
71
|
+
/** The wire form is base16-lower of the raw signature: 128 hex characters. */
|
|
72
|
+
declare const PROOF_SIGNATURE_HEX_LENGTH: number;
|
|
52
73
|
interface ClientProofInput {
|
|
53
74
|
method: string;
|
|
54
75
|
path: string;
|
|
@@ -63,22 +84,42 @@ declare class ProofInputError extends Error {
|
|
|
63
84
|
constructor();
|
|
64
85
|
}
|
|
65
86
|
/**
|
|
66
|
-
* The canonical proof-input string the
|
|
87
|
+
* The canonical proof-input string the signature is taken over.
|
|
67
88
|
*
|
|
68
89
|
* @throws ProofInputError when any field contains a C0 control character.
|
|
69
90
|
*/
|
|
70
91
|
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
92
|
/**
|
|
76
|
-
*
|
|
93
|
+
* The contract's public-key representation — SPKI DER, base64 (the same
|
|
94
|
+
* representation `user_public_keys` and the web ES256 path store) — as a key
|
|
95
|
+
* object. Anything that is not a P-256 EC key is refused at parse time, so a
|
|
96
|
+
* key that could never verify a proof is never registered.
|
|
77
97
|
*
|
|
78
|
-
*
|
|
79
|
-
* public), then the bytes are compared with `timingSafeEqual`.
|
|
98
|
+
* @throws when the input is not base64 SPKI DER naming a P-256 key.
|
|
80
99
|
*/
|
|
81
|
-
declare function
|
|
100
|
+
declare function parseClientProofPublicKey(spkiDerBase64: string): KeyObject;
|
|
101
|
+
/**
|
|
102
|
+
* Verifies a presented proof against `input` and a registered public key.
|
|
103
|
+
*
|
|
104
|
+
* The input is assembled first, so a C0 control character throws no matter
|
|
105
|
+
* what was presented — an unassemblable input is a contract violation, never
|
|
106
|
+
* a proof answer. Then the wire-format gate: a value that is not exactly 128
|
|
107
|
+
* lowercase hex characters — a DER signature, a truncated one, uppercase hex —
|
|
108
|
+
* is invalid before any cryptography happens.
|
|
109
|
+
*
|
|
110
|
+
* @throws ProofInputError when an input field contains a C0 control character.
|
|
111
|
+
*/
|
|
112
|
+
declare function verifyClientProof(input: ClientProofInput, presentedProof: string, publicKey: KeyObject): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Signs `input` with a PKCS#8 DER base64 private key, producing the wire form
|
|
115
|
+
* (raw `r ‖ s`, base16-lower).
|
|
116
|
+
*
|
|
117
|
+
* The verifying half's counterpart, here for tests and dev clients — a
|
|
118
|
+
* production signer lives in the mobile SDKs against hardware-held keys.
|
|
119
|
+
*/
|
|
120
|
+
declare function signClientProof(input: ClientProofInput, privateKeyPkcs8DerBase64: string): string;
|
|
121
|
+
/** Lowercase base16 SHA-256 of `bytes`. */
|
|
122
|
+
declare function sha256Hex(bytes: Uint8Array): string;
|
|
82
123
|
|
|
83
124
|
/** The six wire codes. The SDKs classify by code, never HTTP status. */
|
|
84
125
|
type ClientProofErrorCode = 'PROOF_INVALID' | 'PROOF_REPLAYED' | 'PROOF_EXPIRED' | 'SESSION_REVOKED' | 'PROFILE_REJECTED' | 'CONTRACT_UNSUPPORTED';
|
|
@@ -115,31 +156,6 @@ declare class ClientProofRefusal {
|
|
|
115
156
|
static proofInvalid(): ClientProofRefusal;
|
|
116
157
|
}
|
|
117
158
|
|
|
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
159
|
/** Millisecond clock. Injectable so expiry paths are testable without waiting. */
|
|
144
160
|
interface ClientProofClock {
|
|
145
161
|
nowMillis(): number;
|
|
@@ -164,11 +180,13 @@ interface ClientProofStats {
|
|
|
164
180
|
}
|
|
165
181
|
interface ClientProofStateOptions {
|
|
166
182
|
/**
|
|
167
|
-
* keyId →
|
|
168
|
-
*
|
|
169
|
-
*
|
|
183
|
+
* keyId → registered public key, as SPKI DER base64. The private half
|
|
184
|
+
* never reaches the server: a client generates its keypair (hardware-held
|
|
185
|
+
* on mobile) and only the public key is registered — at construction here,
|
|
186
|
+
* or later through `registerPublicKey` (the dev `/control/register-key`
|
|
187
|
+
* route).
|
|
170
188
|
*/
|
|
171
|
-
|
|
189
|
+
publicKeys: Record<string, string>;
|
|
172
190
|
clock?: ClientProofClock;
|
|
173
191
|
/** @default 600000 */
|
|
174
192
|
sessionTtlMillis?: number;
|
|
@@ -179,9 +197,10 @@ declare const DEFAULT_SESSION_TTL_MILLIS = 600000;
|
|
|
179
197
|
declare class ClientProofState {
|
|
180
198
|
readonly replayWindowMillis: number;
|
|
181
199
|
private readonly clock;
|
|
182
|
-
private readonly
|
|
200
|
+
private readonly initialPublicKeys;
|
|
201
|
+
private readonly publicKeys;
|
|
183
202
|
private readonly sessions;
|
|
184
|
-
/**
|
|
203
|
+
/** The replay ledger — the shared memory implementation, used dev-only here. */
|
|
185
204
|
private readonly spentNonces;
|
|
186
205
|
private readonly revokedKeyIds;
|
|
187
206
|
private readonly holds;
|
|
@@ -193,6 +212,12 @@ declare class ClientProofState {
|
|
|
193
212
|
private itemsListCount;
|
|
194
213
|
private refusalCount;
|
|
195
214
|
constructor(options: ClientProofStateOptions);
|
|
215
|
+
/**
|
|
216
|
+
* Registers (or replaces) the public key `keyId` presents proofs under.
|
|
217
|
+
*
|
|
218
|
+
* @throws when the key is not base64 SPKI DER naming a P-256 key.
|
|
219
|
+
*/
|
|
220
|
+
registerPublicKey(keyId: string, publicKeySpkiDerBase64: string): void;
|
|
196
221
|
/**
|
|
197
222
|
* Runs the contract's checks in the contract's order and returns the
|
|
198
223
|
* refusal, or null when the request is admitted (spending its nonce).
|
|
@@ -217,7 +242,7 @@ declare class ClientProofState {
|
|
|
217
242
|
/** Revokes a key and drops the sessions it opened. */
|
|
218
243
|
revokeKey(keyId: string): void;
|
|
219
244
|
setSessionTtlMillis(millis: number): void;
|
|
220
|
-
/** Returns the state to how it started, counters included. */
|
|
245
|
+
/** Returns the state to how it started, counters and registered keys included. */
|
|
221
246
|
reset(): void;
|
|
222
247
|
/** Makes the next `count` requests to `path` wait `millis` before processing. */
|
|
223
248
|
holdPath(path: string, millis: number, count: number): void;
|
|
@@ -295,6 +320,101 @@ declare function admitClientProofRequest(args: {
|
|
|
295
320
|
requiresSession: boolean;
|
|
296
321
|
body: Uint8Array;
|
|
297
322
|
}): Admission;
|
|
323
|
+
/**
|
|
324
|
+
* The contract header fields, or null when any is absent or malformed.
|
|
325
|
+
*
|
|
326
|
+
* Fetch `Headers` folds a repeated field into one comma-joined value, so
|
|
327
|
+
* "sent more than once" is not directly observable here; a folded value fails
|
|
328
|
+
* either the issuedAt grammar or proof verification instead.
|
|
329
|
+
*
|
|
330
|
+
* Exported for the authenticate middleware's profile path, which runs the
|
|
331
|
+
* same shape checks over arbitrary routes.
|
|
332
|
+
*/
|
|
333
|
+
declare function readCredentials(headers: Headers): ClientProofCredentials | null;
|
|
334
|
+
/** Exported for the authenticate middleware's profile path. */
|
|
335
|
+
declare function isRequestContentType(value: string | null): boolean;
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The ledger key. `JSON.stringify` of the pair, so no crafted clientId/nonce
|
|
339
|
+
* concatenation can collide with another pair — the fields are checked for C0
|
|
340
|
+
* controls only later, at proof verification, so the key must be unambiguous
|
|
341
|
+
* for arbitrary strings.
|
|
342
|
+
*/
|
|
343
|
+
declare function replayLedgerKey(clientId: string, nonce: string): string;
|
|
344
|
+
/**
|
|
345
|
+
* What the middleware's replay ledger must answer. Both methods may reject;
|
|
346
|
+
* the caller refuses the request when they do (fail-closed).
|
|
347
|
+
*/
|
|
348
|
+
interface ClientProofReplayStore {
|
|
349
|
+
/** True when (clientId, nonce) was already spent inside the window. */
|
|
350
|
+
isSpent(clientId: string, nonce: string): Promise<boolean>;
|
|
351
|
+
/**
|
|
352
|
+
* Records the pair as spent. False when it was already spent — the caller
|
|
353
|
+
* lost a race and must answer PROOF_REPLAYED, not accept twice.
|
|
354
|
+
*/
|
|
355
|
+
spend(clientId: string, nonce: string): Promise<boolean>;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The in-memory ledger — the single implementation of the window semantics.
|
|
359
|
+
*
|
|
360
|
+
* Entries carry the millisecond they were recorded at; `prune` drops an entry
|
|
361
|
+
* only once a proof carrying that timestamp would be refused as expired
|
|
362
|
+
* anyway (the exact negation of the admission window check). All methods are
|
|
363
|
+
* synchronous so `ClientProofState.admit` can stay atomic on Node's single
|
|
364
|
+
* thread.
|
|
365
|
+
*/
|
|
366
|
+
declare class MemoryReplayLedger {
|
|
367
|
+
/** replayLedgerKey(...) → the millis it was spent at. */
|
|
368
|
+
private readonly spent;
|
|
369
|
+
isSpent(clientId: string, nonce: string): boolean;
|
|
370
|
+
/** Records the pair at `atMillis`; false when it was already spent. */
|
|
371
|
+
spend(clientId: string, nonce: string, atMillis: number): boolean;
|
|
372
|
+
/** Drops entries older than the window, judged against `nowMillis`. */
|
|
373
|
+
prune(nowMillis: number, windowMillis: number): void;
|
|
374
|
+
get size(): number;
|
|
375
|
+
clear(): void;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The default store: a process-local `MemoryReplayLedger` on the wall clock.
|
|
379
|
+
*
|
|
380
|
+
* Correct for a single process. Behind a multi-instance deployment each
|
|
381
|
+
* instance keeps its own ledger, so a replay against a *different* instance
|
|
382
|
+
* is not seen — that deployment opts into `RedisReplayStore`.
|
|
383
|
+
*/
|
|
384
|
+
declare class MemoryReplayStore implements ClientProofReplayStore {
|
|
385
|
+
private readonly windowMillis;
|
|
386
|
+
private readonly ledger;
|
|
387
|
+
constructor(windowMillis?: number);
|
|
388
|
+
isSpent(clientId: string, nonce: string): Promise<boolean>;
|
|
389
|
+
spend(clientId: string, nonce: string): Promise<boolean>;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* The opt-in shared ledger over `getCache()` (ioredis): `SET NX PX <window>`.
|
|
393
|
+
*
|
|
394
|
+
* The key hashes the pair, so arbitrary clientId/nonce strings become short,
|
|
395
|
+
* safe Redis keys with no ambiguity. `PX` makes Redis expire the entry itself
|
|
396
|
+
* exactly when a proof reusing the nonce would pass the window check again.
|
|
397
|
+
*
|
|
398
|
+
* Fail-closed by construction: when the cache is not configured or a command
|
|
399
|
+
* rejects, the error propagates and the caller refuses the request. Nothing
|
|
400
|
+
* here answers "not spent" on a store it could not reach.
|
|
401
|
+
*/
|
|
402
|
+
declare class RedisReplayStore implements ClientProofReplayStore {
|
|
403
|
+
private readonly windowMillis;
|
|
404
|
+
constructor(windowMillis?: number);
|
|
405
|
+
isSpent(clientId: string, nonce: string): Promise<boolean>;
|
|
406
|
+
spend(clientId: string, nonce: string): Promise<boolean>;
|
|
407
|
+
private cache;
|
|
408
|
+
private key;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Installs the replay store the authenticate middleware uses. Pass
|
|
412
|
+
* `new RedisReplayStore()` to opt into the shared ledger; pass null to return
|
|
413
|
+
* to the in-memory default.
|
|
414
|
+
*/
|
|
415
|
+
declare function configureClientProofReplayStore(store: ClientProofReplayStore | null): void;
|
|
416
|
+
/** The configured store, or a lazily created in-memory default. */
|
|
417
|
+
declare function getClientProofReplayStore(): ClientProofReplayStore;
|
|
298
418
|
|
|
299
419
|
/**
|
|
300
420
|
* The mobile dev-contract types and operations, decoded from / encoded to
|
|
@@ -302,7 +422,7 @@ declare function admitClientProofRequest(args: {
|
|
|
302
422
|
* or an unknown field is "not the request type this operation declares".
|
|
303
423
|
*
|
|
304
424
|
* This module is the source of truth for `operations`. The exported contract
|
|
305
|
-
* bundle (`contracts/mobile/spfn-mobile-contract.
|
|
425
|
+
* bundle (`contracts/mobile/spfn-mobile-contract.json`) is generated from it
|
|
306
426
|
* by `contract-bundle.ts`; spfn-mobile consumes that export rather than the
|
|
307
427
|
* other way round.
|
|
308
428
|
*
|
|
@@ -310,16 +430,35 @@ declare function admitClientProofRequest(args: {
|
|
|
310
430
|
*/
|
|
311
431
|
|
|
312
432
|
interface ContractOperation {
|
|
313
|
-
id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list';
|
|
433
|
+
id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list' | 'auth.enroll.register' | 'auth.enroll.login' | 'auth.enroll.oauthNative' | 'auth.keys.rotate';
|
|
314
434
|
method: 'POST';
|
|
315
435
|
path: string;
|
|
316
|
-
|
|
436
|
+
/**
|
|
437
|
+
* How a call is admitted. `clientProofV1` operations run the proof
|
|
438
|
+
* admission order; `none` operations are the unproven class — accepted
|
|
439
|
+
* with neither proof headers nor a session header, because enrollment is
|
|
440
|
+
* called before any key exists to sign with.
|
|
441
|
+
*/
|
|
442
|
+
authProfile: 'clientProofV1' | 'none';
|
|
317
443
|
requiresSession: boolean;
|
|
318
444
|
requestType: string;
|
|
319
445
|
responseType: string;
|
|
320
446
|
summary: string;
|
|
321
447
|
}
|
|
322
448
|
declare const CONTRACT_OPERATIONS: readonly ContractOperation[];
|
|
449
|
+
/**
|
|
450
|
+
* The `/_auth` surface exported into the mobile contract: enrollment, login
|
|
451
|
+
* and key rotation. These are ordinary SPFN REST routes, not canonical-JSON
|
|
452
|
+
* operations — the dev handler never serves them, and their wire rules are
|
|
453
|
+
* the `restOperations` section of the bundle, not `canonicalJson`.
|
|
454
|
+
*
|
|
455
|
+
* The three `authProfile: 'none'` operations are the unproven class: they are
|
|
456
|
+
* accepted with neither proof headers nor a session header, because they are
|
|
457
|
+
* how a client obtains a key in the first place. `auth.keys.rotate` requires
|
|
458
|
+
* an authenticated caller (a clientProofV1 proof on this surface); an
|
|
459
|
+
* unproven call to it is refused like any failed admission.
|
|
460
|
+
*/
|
|
461
|
+
declare const AUTH_SURFACE_OPERATIONS: readonly ContractOperation[];
|
|
323
462
|
/** The body is canonical JSON but not the declared request type. */
|
|
324
463
|
declare class ContractTypeError extends Error {
|
|
325
464
|
constructor();
|
|
@@ -415,4 +554,4 @@ interface ClientProofGuardOptions {
|
|
|
415
554
|
*/
|
|
416
555
|
declare function createClientProofGuard(state: ClientProofState, options?: ClientProofGuardOptions): MiddlewareHandler;
|
|
417
556
|
|
|
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,
|
|
557
|
+
export { ABSENT_BODY_SHA256, AUTH_SURFACE_OPERATIONS, 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, 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, configureClientProofReplayStore, createClientProofDevHandler, createClientProofGuard, decodeEchoRequest, decodeHandshakeRequest, decodeListItemsRequest, encodeCanonicalJson, encodeEchoResponse, encodeHandshakeResponse, encodeListItemsResponse, getClientProofReplayStore, isCanonicalBytes, isRequestContentType, newHexId, parseCanonicalJson, parseClientProofPublicKey, readCredentials, replayLedgerKey, sha256Hex, signClientProof, systemClock, verifyClientProof };
|