@oxy.so/protocol 1.0.0
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 +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/chain/continuity.js +54 -0
- package/dist/cjs/chain/engine.js +34 -0
- package/dist/cjs/chain/recordStore.js +25 -0
- package/dist/cjs/chain/types.js +22 -0
- package/dist/cjs/chain/verify.js +82 -0
- package/dist/cjs/envelope/canonicalJson.js +107 -0
- package/dist/cjs/envelope/recordId.js +60 -0
- package/dist/cjs/envelope/sign.js +75 -0
- package/dist/cjs/envelope/signingInput.js +32 -0
- package/dist/cjs/identity/resolver.js +50 -0
- package/dist/cjs/index.js +71 -0
- package/dist/cjs/node/constants.js +85 -0
- package/dist/cjs/node/didWebResolver.js +126 -0
- package/dist/cjs/node/httpFetch.js +61 -0
- package/dist/cjs/node/index.js +71 -0
- package/dist/cjs/node/nodeApp.js +344 -0
- package/dist/cjs/node/nodeClient.js +204 -0
- package/dist/cjs/node/rateLimit.js +187 -0
- package/dist/cjs/node/verifyRecord.js +51 -0
- package/dist/cjs/platform/crypto.js +186 -0
- package/dist/cjs/platform/crypto.native.js +204 -0
- package/dist/cjs/platform/expoTypes.js +24 -0
- package/dist/cjs/platform/platform.js +33 -0
- package/dist/cjs/secp256k1.js +148 -0
- package/dist/cjs/transparency/checkpoint.js +79 -0
- package/dist/cjs/transparency/tree.js +197 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/chain/continuity.js +51 -0
- package/dist/esm/chain/engine.js +31 -0
- package/dist/esm/chain/recordStore.js +24 -0
- package/dist/esm/chain/types.js +19 -0
- package/dist/esm/chain/verify.js +78 -0
- package/dist/esm/envelope/canonicalJson.js +104 -0
- package/dist/esm/envelope/recordId.js +56 -0
- package/dist/esm/envelope/sign.js +69 -0
- package/dist/esm/envelope/signingInput.js +29 -0
- package/dist/esm/identity/resolver.js +47 -0
- package/dist/esm/index.js +36 -0
- package/dist/esm/node/constants.js +82 -0
- package/dist/esm/node/didWebResolver.js +122 -0
- package/dist/esm/node/httpFetch.js +55 -0
- package/dist/esm/node/index.js +28 -0
- package/dist/esm/node/nodeApp.js +336 -0
- package/dist/esm/node/nodeClient.js +198 -0
- package/dist/esm/node/rateLimit.js +182 -0
- package/dist/esm/node/verifyRecord.js +48 -0
- package/dist/esm/platform/crypto.js +145 -0
- package/dist/esm/platform/crypto.native.js +196 -0
- package/dist/esm/platform/expoTypes.js +23 -0
- package/dist/esm/platform/platform.js +29 -0
- package/dist/esm/secp256k1.js +137 -0
- package/dist/esm/transparency/checkpoint.js +73 -0
- package/dist/esm/transparency/tree.js +189 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/chain/continuity.d.ts +28 -0
- package/dist/types/chain/engine.d.ts +27 -0
- package/dist/types/chain/recordStore.d.ts +85 -0
- package/dist/types/chain/types.d.ts +79 -0
- package/dist/types/chain/verify.d.ts +45 -0
- package/dist/types/envelope/canonicalJson.d.ts +44 -0
- package/dist/types/envelope/recordId.d.ts +30 -0
- package/dist/types/envelope/sign.d.ts +47 -0
- package/dist/types/envelope/signingInput.d.ts +33 -0
- package/dist/types/identity/resolver.d.ts +67 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/node/constants.d.ts +80 -0
- package/dist/types/node/didWebResolver.d.ts +47 -0
- package/dist/types/node/httpFetch.d.ts +60 -0
- package/dist/types/node/index.d.ts +28 -0
- package/dist/types/node/nodeApp.d.ts +120 -0
- package/dist/types/node/nodeClient.d.ts +135 -0
- package/dist/types/node/rateLimit.d.ts +95 -0
- package/dist/types/node/verifyRecord.d.ts +41 -0
- package/dist/types/platform/crypto.d.ts +93 -0
- package/dist/types/platform/crypto.native.d.ts +77 -0
- package/dist/types/platform/expoTypes.d.ts +99 -0
- package/dist/types/platform/platform.d.ts +25 -0
- package/dist/types/secp256k1.d.ts +45 -0
- package/dist/types/transparency/checkpoint.d.ts +71 -0
- package/dist/types/transparency/tree.d.ts +135 -0
- package/package.json +157 -0
- package/src/__tests__/canonicalJson.test.ts +116 -0
- package/src/__tests__/chain.test.ts +279 -0
- package/src/__tests__/didWebResolver.test.ts +132 -0
- package/src/__tests__/envelope.test.ts +267 -0
- package/src/__tests__/nodeApp.test.ts +410 -0
- package/src/__tests__/nodeClient.test.ts +177 -0
- package/src/__tests__/nodeHarness.ts +151 -0
- package/src/__tests__/optionalNativePeers.test.ts +233 -0
- package/src/__tests__/rateLimit.test.ts +268 -0
- package/src/__tests__/runnerGuard.test.ts +85 -0
- package/src/__tests__/secp256k1.test.ts +118 -0
- package/src/__tests__/transparency.test.ts +353 -0
- package/src/chain/continuity.ts +59 -0
- package/src/chain/engine.ts +43 -0
- package/src/chain/recordStore.ts +98 -0
- package/src/chain/types.ts +85 -0
- package/src/chain/verify.ts +102 -0
- package/src/envelope/canonicalJson.ts +120 -0
- package/src/envelope/recordId.ts +63 -0
- package/src/envelope/sign.ts +86 -0
- package/src/envelope/signingInput.ts +48 -0
- package/src/identity/resolver.ts +90 -0
- package/src/index.ts +101 -0
- package/src/node/constants.ts +105 -0
- package/src/node/didWebResolver.ts +162 -0
- package/src/node/httpFetch.ts +88 -0
- package/src/node/index.ts +87 -0
- package/src/node/nodeApp.ts +471 -0
- package/src/node/nodeClient.ts +322 -0
- package/src/node/rateLimit.ts +233 -0
- package/src/node/verifyRecord.ts +60 -0
- package/src/platform/crypto.native.ts +251 -0
- package/src/platform/crypto.ts +172 -0
- package/src/platform/expoTypes.ts +99 -0
- package/src/platform/platform.ts +31 -0
- package/src/secp256k1.ts +207 -0
- package/src/transparency/checkpoint.ts +109 -0
- package/src/transparency/tree.ts +258 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NodeClient` — the HTTP client that drives an Oxy-protocol data node's
|
|
3
|
+
* routes (head / log / records / blobs). It is the OUTBOUND half of the node
|
|
4
|
+
* protocol: oxy-api uses it to PULL a user's chain back from their node; a
|
|
5
|
+
* future Mention backend (B3) uses it to drive a node + push records/blobs.
|
|
6
|
+
*
|
|
7
|
+
* The client is transport-agnostic — it takes an injected {@link NodeFetch} so
|
|
8
|
+
* the protocol package never depends on `@oxy.so/core`. Oxy supplies an adapter
|
|
9
|
+
* over `@oxy.so/core/server`'s `safeFetch` (HTTPS-only, DNS-pinned, private-IP
|
|
10
|
+
* denylist, bounded redirects); a test supplies an in-process stub. Every
|
|
11
|
+
* response body is read with a hard byte ceiling, so a node cannot stream an
|
|
12
|
+
* unbounded body into the caller.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { SignedRecordEnvelope } from '@oxy.so/contracts';
|
|
16
|
+
import {
|
|
17
|
+
type NodeFetch,
|
|
18
|
+
type NodeFetchInit,
|
|
19
|
+
readBoundedBytes,
|
|
20
|
+
readBoundedJson,
|
|
21
|
+
} from './httpFetch';
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_CLIENT_MAX_REDIRECTS,
|
|
24
|
+
DEFAULT_CLIENT_TIMEOUT_MS,
|
|
25
|
+
DEFAULT_HEAD_MAX_BYTES,
|
|
26
|
+
DEFAULT_LOG_MAX_BYTES,
|
|
27
|
+
DEFAULT_MAX_BLOB_BYTES,
|
|
28
|
+
DEFAULT_WRITE_RESPONSE_MAX_BYTES,
|
|
29
|
+
NODE_BLOBS_PATH,
|
|
30
|
+
NODE_HEAD_PATH,
|
|
31
|
+
NODE_LOG_PATH,
|
|
32
|
+
NODE_RECORDS_PATH,
|
|
33
|
+
NODE_SYNC_PUSH_PATH,
|
|
34
|
+
OWNER_AUTH_HEADERS,
|
|
35
|
+
} from './constants';
|
|
36
|
+
|
|
37
|
+
/** The chain head a node reports at `GET /oxy/head`. */
|
|
38
|
+
export interface NodeHead {
|
|
39
|
+
seq: number | null;
|
|
40
|
+
headRecordId: string | null;
|
|
41
|
+
recordCount: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One ordered page of a node's log (`GET /oxy/log`). */
|
|
45
|
+
export interface NodeLogPage {
|
|
46
|
+
/**
|
|
47
|
+
* The raw log items, returned VERBATIM (not re-parsed) — the caller validates
|
|
48
|
+
* + verifies each against the envelope schema. Preserves the node's exact wire
|
|
49
|
+
* shape so a puller's own verification is the trust boundary.
|
|
50
|
+
*/
|
|
51
|
+
records: unknown[];
|
|
52
|
+
count: number;
|
|
53
|
+
head: { seq: number; headRecordId: string } | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Outcome of an owner write (`POST /records`). */
|
|
57
|
+
export interface NodeWriteResult {
|
|
58
|
+
recordId: string;
|
|
59
|
+
seq: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Outcome of an owner blob pin (`PUT /blobs/:hash`). */
|
|
63
|
+
export interface NodeBlobPutResult {
|
|
64
|
+
hash: string;
|
|
65
|
+
size: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Owner-signed authorization for a blob pin (caller signs; client sends headers). */
|
|
69
|
+
export interface NodeBlobPinAuth {
|
|
70
|
+
publicKey: string;
|
|
71
|
+
signature: string;
|
|
72
|
+
timestamp: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A non-2xx node response (or a node that returned a malformed body). */
|
|
76
|
+
export class NodeClientError extends Error {
|
|
77
|
+
constructor(
|
|
78
|
+
message: string,
|
|
79
|
+
public readonly status?: number,
|
|
80
|
+
public readonly reason?: string,
|
|
81
|
+
) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = 'NodeClientError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Construction options for a {@link NodeClient}. */
|
|
88
|
+
export interface NodeClientOptions {
|
|
89
|
+
/** The node's HTTPS base URL (no trailing slash). */
|
|
90
|
+
baseUrl: string;
|
|
91
|
+
/** The injected transport (an adapter over `safeFetch`, or a test stub). */
|
|
92
|
+
fetch: NodeFetch;
|
|
93
|
+
/** Time-to-first-byte deadline per request (ms). */
|
|
94
|
+
headersTimeoutMs?: number;
|
|
95
|
+
/** Redirect budget per request (each re-validated by the transport). */
|
|
96
|
+
maxRedirects?: number;
|
|
97
|
+
/** Bounded read ceiling for a `/oxy/head` response. */
|
|
98
|
+
headMaxBytes?: number;
|
|
99
|
+
/** Bounded read ceiling for a `/oxy/log` page response. */
|
|
100
|
+
logMaxBytes?: number;
|
|
101
|
+
/** Bounded read ceiling for a small write/JSON response. */
|
|
102
|
+
writeResponseMaxBytes?: number;
|
|
103
|
+
/** Bounded read ceiling for a fetched blob. */
|
|
104
|
+
blobMaxBytes?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readError(body: unknown): string | undefined {
|
|
108
|
+
if (typeof body === 'object' && body !== null && typeof (body as { error?: unknown }).error === 'string') {
|
|
109
|
+
return (body as { error: string }).error;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Trim every trailing slash from a base URL in LINEAR time.
|
|
116
|
+
*
|
|
117
|
+
* Replaces an anchored-quantifier regex (`/\/+$/`) whose backtracking is a
|
|
118
|
+
* polynomial-ReDoS sink on a long all-slash input; a single-pass scan is O(n)
|
|
119
|
+
* with no ReDoS surface.
|
|
120
|
+
*/
|
|
121
|
+
export function trimTrailingSlashes(value: string): string {
|
|
122
|
+
let end = value.length;
|
|
123
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47 /* '/' */) {
|
|
124
|
+
end -= 1;
|
|
125
|
+
}
|
|
126
|
+
return end === value.length ? value : value.slice(0, end);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class NodeClient {
|
|
130
|
+
private readonly baseUrl: string;
|
|
131
|
+
private readonly fetch: NodeFetch;
|
|
132
|
+
private readonly headersTimeoutMs: number;
|
|
133
|
+
private readonly maxRedirects: number;
|
|
134
|
+
private readonly headMaxBytes: number;
|
|
135
|
+
private readonly logMaxBytes: number;
|
|
136
|
+
private readonly writeResponseMaxBytes: number;
|
|
137
|
+
private readonly blobMaxBytes: number;
|
|
138
|
+
|
|
139
|
+
constructor(options: NodeClientOptions) {
|
|
140
|
+
this.baseUrl = trimTrailingSlashes(options.baseUrl);
|
|
141
|
+
this.fetch = options.fetch;
|
|
142
|
+
this.headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_CLIENT_TIMEOUT_MS;
|
|
143
|
+
this.maxRedirects = options.maxRedirects ?? DEFAULT_CLIENT_MAX_REDIRECTS;
|
|
144
|
+
this.headMaxBytes = options.headMaxBytes ?? DEFAULT_HEAD_MAX_BYTES;
|
|
145
|
+
this.logMaxBytes = options.logMaxBytes ?? DEFAULT_LOG_MAX_BYTES;
|
|
146
|
+
this.writeResponseMaxBytes = options.writeResponseMaxBytes ?? DEFAULT_WRITE_RESPONSE_MAX_BYTES;
|
|
147
|
+
this.blobMaxBytes = options.blobMaxBytes ?? DEFAULT_MAX_BLOB_BYTES;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Base request options shared by every call (timeout + redirect budget). */
|
|
151
|
+
private init(extra: Partial<NodeFetchInit> & Pick<NodeFetchInit, 'method'>): NodeFetchInit {
|
|
152
|
+
return {
|
|
153
|
+
headersTimeoutMs: this.headersTimeoutMs,
|
|
154
|
+
maxRedirects: this.maxRedirects,
|
|
155
|
+
...extra,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The node's current chain head. Throws {@link NodeClientError} on a non-2xx. */
|
|
160
|
+
async head(): Promise<NodeHead> {
|
|
161
|
+
const res = await this.fetch(`${this.baseUrl}${NODE_HEAD_PATH}`, this.init({ method: 'GET' }));
|
|
162
|
+
if (res.status < 200 || res.status >= 300) {
|
|
163
|
+
res.destroy();
|
|
164
|
+
throw new NodeClientError(`node ${NODE_HEAD_PATH} responded HTTP ${res.status}`, res.status);
|
|
165
|
+
}
|
|
166
|
+
const body = await readBoundedJson(res, this.headMaxBytes);
|
|
167
|
+
const obj = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>;
|
|
168
|
+
return {
|
|
169
|
+
seq: typeof obj.seq === 'number' ? obj.seq : null,
|
|
170
|
+
headRecordId: typeof obj.headRecordId === 'string' ? obj.headRecordId : null,
|
|
171
|
+
recordCount: typeof obj.recordCount === 'number' ? obj.recordCount : 0,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One ordered page of the node's log strictly after `sinceSeq` (pass `-1` from
|
|
177
|
+
* genesis), capped at `limit`. Throws {@link NodeClientError} on a non-2xx or a
|
|
178
|
+
* response missing the `records` array.
|
|
179
|
+
*/
|
|
180
|
+
async log(sinceSeq: number, limit: number): Promise<NodeLogPage> {
|
|
181
|
+
// A genesis cursor (`sinceSeq < 0`) is expressed by OMITTING `since` — the
|
|
182
|
+
// node reads an absent cursor as "from genesis". A negative numeric `since`
|
|
183
|
+
// is not a valid cursor on the wire (only an absent one, a non-negative seq,
|
|
184
|
+
// or a recordId), so omitting it is the correct way to request the whole log.
|
|
185
|
+
const sinceParam = sinceSeq >= 0 ? `since=${encodeURIComponent(String(sinceSeq))}&` : '';
|
|
186
|
+
const url = `${this.baseUrl}${NODE_LOG_PATH}?${sinceParam}limit=${encodeURIComponent(String(limit))}`;
|
|
187
|
+
const res = await this.fetch(url, this.init({ method: 'GET' }));
|
|
188
|
+
if (res.status < 200 || res.status >= 300) {
|
|
189
|
+
res.destroy();
|
|
190
|
+
throw new NodeClientError(`node ${NODE_LOG_PATH} responded HTTP ${res.status}`, res.status);
|
|
191
|
+
}
|
|
192
|
+
const body = await readBoundedJson(res, this.logMaxBytes);
|
|
193
|
+
const records = (body as { records?: unknown }).records;
|
|
194
|
+
if (!Array.isArray(records)) {
|
|
195
|
+
throw new NodeClientError(`node ${NODE_LOG_PATH} returned no records array`, res.status);
|
|
196
|
+
}
|
|
197
|
+
const headRaw = (body as { head?: unknown }).head;
|
|
198
|
+
const head =
|
|
199
|
+
typeof headRaw === 'object' &&
|
|
200
|
+
headRaw !== null &&
|
|
201
|
+
typeof (headRaw as { seq?: unknown }).seq === 'number' &&
|
|
202
|
+
typeof (headRaw as { headRecordId?: unknown }).headRecordId === 'string'
|
|
203
|
+
? { seq: (headRaw as { seq: number }).seq, headRecordId: (headRaw as { headRecordId: string }).headRecordId }
|
|
204
|
+
: null;
|
|
205
|
+
return { records, count: records.length, head };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Write a single owner-signed envelope (`POST /records`). Throws
|
|
210
|
+
* {@link NodeClientError} (carrying the node's `reason`) on any non-2xx — a
|
|
211
|
+
* chain rejection (`chain_gap`/`chain_fork`/`bad_seq`/`chain_conflict`) or an
|
|
212
|
+
* authorization failure.
|
|
213
|
+
*/
|
|
214
|
+
async writeRecord(envelope: SignedRecordEnvelope): Promise<NodeWriteResult> {
|
|
215
|
+
const res = await this.fetch(
|
|
216
|
+
`${this.baseUrl}${NODE_RECORDS_PATH}`,
|
|
217
|
+
this.init({
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: { 'Content-Type': 'application/json' },
|
|
220
|
+
body: Buffer.from(JSON.stringify(envelope), 'utf8'),
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
const body = await readBoundedJson(res, this.writeResponseMaxBytes);
|
|
224
|
+
if (res.status < 200 || res.status >= 300) {
|
|
225
|
+
const reason = readError(body);
|
|
226
|
+
throw new NodeClientError(
|
|
227
|
+
`node ${NODE_RECORDS_PATH} responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`,
|
|
228
|
+
res.status,
|
|
229
|
+
reason,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const obj = body as { recordId?: unknown; seq?: unknown };
|
|
233
|
+
if (typeof obj.recordId !== 'string' || typeof obj.seq !== 'number') {
|
|
234
|
+
throw new NodeClientError(`node ${NODE_RECORDS_PATH} returned a malformed write result`, res.status);
|
|
235
|
+
}
|
|
236
|
+
return { recordId: obj.recordId, seq: obj.seq };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Push a batch of owner-signed envelopes (`POST /sync/push`). Returns the
|
|
241
|
+
* node's per-item results. Throws {@link NodeClientError} only on a non-2xx
|
|
242
|
+
* batch-level failure (`invalid_batch` / `batch_too_large`).
|
|
243
|
+
*/
|
|
244
|
+
async pushRecords(
|
|
245
|
+
envelopes: SignedRecordEnvelope[],
|
|
246
|
+
): Promise<{ accepted: number; results: Array<{ ok: boolean; recordId?: string; seq?: number; reason?: string }> }> {
|
|
247
|
+
const res = await this.fetch(
|
|
248
|
+
`${this.baseUrl}${NODE_SYNC_PUSH_PATH}`,
|
|
249
|
+
this.init({
|
|
250
|
+
method: 'POST',
|
|
251
|
+
headers: { 'Content-Type': 'application/json' },
|
|
252
|
+
body: Buffer.from(JSON.stringify({ records: envelopes }), 'utf8'),
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
const body = await readBoundedJson(res, this.writeResponseMaxBytes);
|
|
256
|
+
if (res.status < 200 || res.status >= 300) {
|
|
257
|
+
const reason = readError(body);
|
|
258
|
+
throw new NodeClientError(
|
|
259
|
+
`node ${NODE_SYNC_PUSH_PATH} responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`,
|
|
260
|
+
res.status,
|
|
261
|
+
reason,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const obj = body as { accepted?: unknown; results?: unknown };
|
|
265
|
+
return {
|
|
266
|
+
accepted: typeof obj.accepted === 'number' ? obj.accepted : 0,
|
|
267
|
+
results: Array.isArray(obj.results)
|
|
268
|
+
? (obj.results as Array<{ ok: boolean; recordId?: string; seq?: number; reason?: string }>)
|
|
269
|
+
: [],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Fetch a content-addressed blob. Returns `null` on a 404; throws on other non-2xx. */
|
|
274
|
+
async getBlob(hash: string): Promise<Buffer | null> {
|
|
275
|
+
const res = await this.fetch(`${this.baseUrl}${NODE_BLOBS_PATH}/${encodeURIComponent(hash)}`, this.init({ method: 'GET' }));
|
|
276
|
+
if (res.status === 404) {
|
|
277
|
+
res.destroy();
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
if (res.status < 200 || res.status >= 300) {
|
|
281
|
+
res.destroy();
|
|
282
|
+
throw new NodeClientError(`node ${NODE_BLOBS_PATH}/:hash responded HTTP ${res.status}`, res.status);
|
|
283
|
+
}
|
|
284
|
+
return readBoundedBytes(res, this.blobMaxBytes);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Pin a content-addressed blob with an owner-signed authorization
|
|
289
|
+
* (`PUT /blobs/:hash`). The caller signs the pin (it holds the owner key) and
|
|
290
|
+
* passes the resulting `{ publicKey, signature, timestamp }`; the client sets
|
|
291
|
+
* the owner-auth headers. Throws {@link NodeClientError} on a non-2xx.
|
|
292
|
+
*/
|
|
293
|
+
async putBlob(hash: string, bytes: Uint8Array, auth: NodeBlobPinAuth): Promise<NodeBlobPutResult> {
|
|
294
|
+
const res = await this.fetch(
|
|
295
|
+
`${this.baseUrl}${NODE_BLOBS_PATH}/${encodeURIComponent(hash)}`,
|
|
296
|
+
this.init({
|
|
297
|
+
method: 'PUT',
|
|
298
|
+
headers: {
|
|
299
|
+
'Content-Type': 'application/octet-stream',
|
|
300
|
+
[OWNER_AUTH_HEADERS.publicKey]: auth.publicKey,
|
|
301
|
+
[OWNER_AUTH_HEADERS.signature]: auth.signature,
|
|
302
|
+
[OWNER_AUTH_HEADERS.timestamp]: String(auth.timestamp),
|
|
303
|
+
},
|
|
304
|
+
body: bytes,
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
const body = await readBoundedJson(res, this.writeResponseMaxBytes);
|
|
308
|
+
if (res.status < 200 || res.status >= 300) {
|
|
309
|
+
const reason = readError(body);
|
|
310
|
+
throw new NodeClientError(
|
|
311
|
+
`node ${NODE_BLOBS_PATH}/:hash responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`,
|
|
312
|
+
res.status,
|
|
313
|
+
reason,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const obj = body as { hash?: unknown; size?: unknown };
|
|
317
|
+
if (typeof obj.hash !== 'string' || typeof obj.size !== 'number') {
|
|
318
|
+
throw new NodeClientError(`node ${NODE_BLOBS_PATH}/:hash returned a malformed pin result`, res.status);
|
|
319
|
+
}
|
|
320
|
+
return { hash: obj.hash, size: obj.size };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, dependency-free fixed-window per-IP rate limiter for the node app's
|
|
3
|
+
* owner-authorized write routes.
|
|
4
|
+
*
|
|
5
|
+
* The node is a single-writer model (only the owner key may write), so the
|
|
6
|
+
* limiter is a defence-in-depth budget on the unauthenticated edge — it caps the
|
|
7
|
+
* request rate BEFORE signature verification so a flood of bogus envelopes can't
|
|
8
|
+
* pin CPU on crypto. It is intentionally process-local (a single node serves one
|
|
9
|
+
* owner's repo); there is no shared store to coordinate.
|
|
10
|
+
*
|
|
11
|
+
* Fixed-window counting, one budget per client: each key gets `max` requests per
|
|
12
|
+
* `windowMs`, and the window resets lazily on the first request after it elapses.
|
|
13
|
+
* The key is a SALTED HASH of the client address, never the address itself — see
|
|
14
|
+
* {@link clientRateLimitKey}.
|
|
15
|
+
*
|
|
16
|
+
* Bounded memory (defence against a key-rotation DoS — spoofed IPs / many DIDs
|
|
17
|
+
* growing the map without limit → memory exhaustion):
|
|
18
|
+
* - An ACTIVE periodic sweep on an `unref()`'d interval deletes every entry
|
|
19
|
+
* whose window has fully elapsed, so keys that are never touched again do not
|
|
20
|
+
* leak forever (lazy expiry-on-access alone cannot reclaim them). The
|
|
21
|
+
* interval is `unref()`'d so it never keeps the node process alive, and
|
|
22
|
+
* {@link RateLimiter.stop} clears it for a clean lifecycle teardown.
|
|
23
|
+
* - A hard cap on the number of tracked keys ({@link RateLimitConfig.maxEntries})
|
|
24
|
+
* evicts the OLDEST window (insertion-order LRU) when exceeded — a synchronous
|
|
25
|
+
* backstop against a burst that arrives between sweeps.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
29
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
30
|
+
|
|
31
|
+
// The package's `lib` includes `DOM` (the isomorphic root code uses Web Crypto),
|
|
32
|
+
// so the ambient `setInterval` overload TypeScript picks for a bare call is the
|
|
33
|
+
// browser one returning `number` — which has no `.unref()`. This `node/` subpath
|
|
34
|
+
// is Node-only; reach the Node timer globals through their `@types/node`
|
|
35
|
+
// signatures so the handle is correctly `NodeJS.Timeout` (no cast, no shadowing).
|
|
36
|
+
// Resolved at call time (not captured at module load) so test fake-timers that
|
|
37
|
+
// swap the globals still drive the sweep.
|
|
38
|
+
function nodeSetInterval(handler: () => void, ms: number): NodeJS.Timeout {
|
|
39
|
+
const set: (handler: () => void, ms: number) => NodeJS.Timeout = globalThis.setInterval;
|
|
40
|
+
return set(handler, ms);
|
|
41
|
+
}
|
|
42
|
+
function nodeClearInterval(timer: NodeJS.Timeout): void {
|
|
43
|
+
const clear: (timer: NodeJS.Timeout) => void = globalThis.clearInterval;
|
|
44
|
+
clear(timer);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The HMAC key under which client addresses are hashed into rate-limit keys.
|
|
49
|
+
* 256 CSPRNG bits, minted once when this module is first loaded and held only in
|
|
50
|
+
* memory — never read from a config, never written anywhere, never sent.
|
|
51
|
+
*
|
|
52
|
+
* ## Why the salt is deliberately EPHEMERAL, and what a stable one would cost
|
|
53
|
+
*
|
|
54
|
+
* A rate-limit window is short-lived (`windowMs`, seconds to a minute) and this
|
|
55
|
+
* limiter is process-local by design — a node serves one owner's repo and there
|
|
56
|
+
* is no shared store to coordinate. So nothing here needs a key to mean the same
|
|
57
|
+
* thing after a restart, or to mean the same thing on another node. That makes a
|
|
58
|
+
* per-process salt not merely sufficient but BETTER than a configured one:
|
|
59
|
+
*
|
|
60
|
+
* - there is no value to distribute, so there is nothing for a node operator to
|
|
61
|
+
* get wrong, nothing to rotate, and nothing to leak from an env file, a
|
|
62
|
+
* process listing or a container image;
|
|
63
|
+
* - the mapping dies with the process, so the same address hashes to a
|
|
64
|
+
* different key after every restart and the keys correlate to nothing once
|
|
65
|
+
* the process exits.
|
|
66
|
+
*
|
|
67
|
+
* A stable salt (an env var, a file) would buy exactly one thing this limiter
|
|
68
|
+
* does not want — a client identifier that survives a restart and can be compared
|
|
69
|
+
* across nodes — in exchange for a config burden and a secret at rest. That is
|
|
70
|
+
* the trade, and it is why this is not configurable.
|
|
71
|
+
*
|
|
72
|
+
* ## What the hash does and does not buy, stated honestly
|
|
73
|
+
*
|
|
74
|
+
* It removes the raw address from the process's data structures: the limiter's
|
|
75
|
+
* Map holds digests, so an address is no longer sitting in memory as a key for
|
|
76
|
+
* the lifetime of a window, and nothing downstream can casually read one back
|
|
77
|
+
* out. What it does NOT claim is secrecy against an attacker who already has the
|
|
78
|
+
* live process — the salt is in the same heap, and with it the IPv4 space is
|
|
79
|
+
* enumerable. That is the general reason hashing is not an acceptable AT-REST
|
|
80
|
+
* form for an address anywhere in Oxy; this value is never at rest.
|
|
81
|
+
*/
|
|
82
|
+
const CLIENT_KEY_SALT = randomBytes(32);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The rate-limit key for a request: a salted hash of the client address, or the
|
|
86
|
+
* `'unknown'` sentinel when Express resolved no address at all (a request whose
|
|
87
|
+
* address is unknown cannot be budgeted individually, so all of them share one
|
|
88
|
+
* bucket — the same behaviour this limiter has always had).
|
|
89
|
+
*
|
|
90
|
+
* Truncated to 96 bits, which keeps a tracked entry to a short string beside its
|
|
91
|
+
* two numbers (the memory-bounding rationale on {@link RateLimitConfig.maxEntries}
|
|
92
|
+
* assumes exactly that). At the 10 000-entry cap a collision — two clients
|
|
93
|
+
* sharing one budget — has probability around 10⁸/2⁹⁷, i.e. never.
|
|
94
|
+
*
|
|
95
|
+
* Residue, named rather than left implicit: the address is hashed VERBATIM, so an
|
|
96
|
+
* IPv6 client that rotates through its /64 still mints a fresh key per address,
|
|
97
|
+
* exactly as it did before this was hashed. oxy-api's `hashedIpKey` buckets IPv6
|
|
98
|
+
* to /56 first to close that; doing the same here is a rate-limiting change with
|
|
99
|
+
* its own reasoning (it makes a whole prefix share one budget) and is deliberately
|
|
100
|
+
* not folded into a privacy fix.
|
|
101
|
+
*
|
|
102
|
+
* Exported for {@link createRateLimiter}'s own tests, not part of
|
|
103
|
+
* `@oxy.so/protocol/node`'s public surface — it is not re-exported by the barrel.
|
|
104
|
+
*/
|
|
105
|
+
export function clientRateLimitKey(req: Request): string {
|
|
106
|
+
const ip = req.ip;
|
|
107
|
+
if (!ip) {
|
|
108
|
+
return 'unknown';
|
|
109
|
+
}
|
|
110
|
+
// Single-purpose salt: it derives this key and nothing else, so there is no
|
|
111
|
+
// second derivation to namespace against (oxy-api's `hashedIpKey` prefixes
|
|
112
|
+
// `rl|` because its salt is shared with deviceId derivation). A future second
|
|
113
|
+
// use of this salt would need a namespace, or its own salt.
|
|
114
|
+
return createHmac('sha256', CLIENT_KEY_SALT).update(ip).digest('hex').slice(0, 24);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A request-rate budget: at most `max` requests per `windowMs`. */
|
|
118
|
+
export interface RateLimitConfig {
|
|
119
|
+
/** The rolling window length, in milliseconds. */
|
|
120
|
+
readonly windowMs: number;
|
|
121
|
+
/** The maximum number of requests permitted within one window. */
|
|
122
|
+
readonly max: number;
|
|
123
|
+
/**
|
|
124
|
+
* Hard cap on the number of distinct keys (client identifiers) tracked at
|
|
125
|
+
* once. When the map exceeds this, the oldest-inserted window is evicted as a
|
|
126
|
+
* synchronous backstop against a burst of distinct keys arriving between
|
|
127
|
+
* sweeps. Defaults to {@link DEFAULT_MAX_RATE_LIMIT_ENTRIES}.
|
|
128
|
+
*/
|
|
129
|
+
readonly maxEntries?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Default budget for owner write routes (generous — single-writer model). */
|
|
133
|
+
export const DEFAULT_WRITE_RATE_LIMIT: RateLimitConfig = { windowMs: 60_000, max: 60 };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Default hard cap on tracked keys. Sized so the map's worst-case footprint
|
|
137
|
+
* stays small (each entry is a short string key + two numbers) while never
|
|
138
|
+
* evicting a legitimately active key for the single-writer node — the owner
|
|
139
|
+
* drives traffic from a handful of IPs, far below this ceiling.
|
|
140
|
+
*/
|
|
141
|
+
export const DEFAULT_MAX_RATE_LIMIT_ENTRIES = 10_000;
|
|
142
|
+
|
|
143
|
+
interface WindowCounter {
|
|
144
|
+
/** Epoch ms when the current window started. */
|
|
145
|
+
start: number;
|
|
146
|
+
/** Requests counted in the current window. */
|
|
147
|
+
count: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The Express middleware returned by {@link createRateLimiter}, carrying a
|
|
152
|
+
* {@link stop} hook so the owning app can clear the background sweep on shutdown.
|
|
153
|
+
*/
|
|
154
|
+
export interface RateLimiter {
|
|
155
|
+
(req: Request, res: Response, next: NextFunction): void;
|
|
156
|
+
/**
|
|
157
|
+
* Stop the background sweep timer. Idempotent. Called by the node app's
|
|
158
|
+
* graceful-shutdown path; not required for process exit (the timer is
|
|
159
|
+
* `unref()`'d) but keeps long-lived test harnesses leak-free.
|
|
160
|
+
*/
|
|
161
|
+
stop(): void;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build an Express middleware enforcing a fixed-window per-client rate limit.
|
|
166
|
+
* Exceeding the budget responds `429 { error: 'rate_limited' }` and does not call
|
|
167
|
+
* `next`. The key is {@link clientRateLimitKey} — a salted hash of Express's
|
|
168
|
+
* resolved client IP, so no address is held in the tracked map.
|
|
169
|
+
*
|
|
170
|
+
* The returned middleware owns a background sweep timer; call {@link RateLimiter.stop}
|
|
171
|
+
* to release it (e.g. on app shutdown).
|
|
172
|
+
*/
|
|
173
|
+
export function createRateLimiter(config: RateLimitConfig): RateLimiter {
|
|
174
|
+
const windows = new Map<string, WindowCounter>();
|
|
175
|
+
const maxEntries = config.maxEntries ?? DEFAULT_MAX_RATE_LIMIT_ENTRIES;
|
|
176
|
+
|
|
177
|
+
// Active sweep: delete every entry whose window has fully elapsed. Running on
|
|
178
|
+
// a timer (rather than only on request arrival) reclaims keys that are never
|
|
179
|
+
// touched again, so a churn of distinct IPs cannot leak memory once traffic
|
|
180
|
+
// for those keys stops. One pass per window is sufficient: an entry lives at
|
|
181
|
+
// most `2 * windowMs` before a sweep removes it.
|
|
182
|
+
function sweepExpired(): void {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
for (const [key, counter] of windows) {
|
|
185
|
+
if (now - counter.start >= config.windowMs) {
|
|
186
|
+
windows.delete(key);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sweepTimer = nodeSetInterval(sweepExpired, config.windowMs);
|
|
192
|
+
// Never let the sweep keep the node process alive on its own.
|
|
193
|
+
sweepTimer.unref();
|
|
194
|
+
|
|
195
|
+
function rateLimit(req: Request, res: Response, next: NextFunction): void {
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
const key = clientRateLimitKey(req);
|
|
198
|
+
const counter = windows.get(key);
|
|
199
|
+
|
|
200
|
+
if (!counter || now - counter.start >= config.windowMs) {
|
|
201
|
+
// Hard cap backstop: if a burst of distinct keys outran the sweep, evict
|
|
202
|
+
// the oldest-inserted window before admitting a new key. A `Map` preserves
|
|
203
|
+
// insertion order, so its first key is the oldest tracked entry.
|
|
204
|
+
if (!counter && windows.size >= maxEntries) {
|
|
205
|
+
const oldest = windows.keys().next().value;
|
|
206
|
+
if (oldest !== undefined) {
|
|
207
|
+
windows.delete(oldest);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
windows.set(key, { start: now, count: 1 });
|
|
211
|
+
next();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (counter.count >= config.max) {
|
|
216
|
+
const retryAfterSec = Math.ceil((counter.start + config.windowMs - now) / 1000);
|
|
217
|
+
res.setHeader('Retry-After', String(Math.max(retryAfterSec, 1)));
|
|
218
|
+
res.status(429).json({ error: 'rate_limited' });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
counter.count += 1;
|
|
223
|
+
next();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Attach the lifecycle hook to the middleware, yielding the `RateLimiter`
|
|
227
|
+
// callable-with-`stop` without a cast.
|
|
228
|
+
return Object.assign(rateLimit, {
|
|
229
|
+
stop(): void {
|
|
230
|
+
nodeClearInterval(sweepTimer);
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record verification for a data node — reuses the protocol envelope primitives
|
|
3
|
+
* so a record verifies on the node with the EXACT code Oxy uses. No crypto is
|
|
4
|
+
* re-implemented:
|
|
5
|
+
*
|
|
6
|
+
* - {@link verifyEnvelopeSignature} recomputes the canonical signing input (the
|
|
7
|
+
* bytes the signature covers) from the envelope's own fields and checks the
|
|
8
|
+
* secp256k1 DER signature against the envelope's embedded `publicKey`.
|
|
9
|
+
* - {@link computeRecordId} recomputes `recordId = sha256(signingInput)` — the
|
|
10
|
+
* content address used as the chain's `prev` pointer.
|
|
11
|
+
*
|
|
12
|
+
* The envelope shape is validated with the shared `signedRecordEnvelopeSchema`.
|
|
13
|
+
* A node is a v2 hash chain, so only v2 envelopes (carrying
|
|
14
|
+
* `seq`/`prev`/`collection`/`rkey`) are accepted; v1 singletons have no chain
|
|
15
|
+
* coordinates.
|
|
16
|
+
*
|
|
17
|
+
* Verification here proves the signature is internally consistent with the
|
|
18
|
+
* embedded `publicKey`. Whether that key is authorized for the node is the OWNER
|
|
19
|
+
* check (the injected owner-key authority) — on a node the authority is the
|
|
20
|
+
* configured owner public key, not a DID lookup.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { signedRecordEnvelopeSchema, type SignedRecordEnvelope } from '@oxy.so/contracts';
|
|
24
|
+
import { computeRecordId } from '../envelope/recordId';
|
|
25
|
+
import { verifyEnvelopeSignature } from '../envelope/sign';
|
|
26
|
+
|
|
27
|
+
/** Stable, machine-readable reasons an envelope can fail node verification. */
|
|
28
|
+
export type NodeVerifyRejectionReason = 'invalid_envelope' | 'not_v2' | 'bad_signature';
|
|
29
|
+
|
|
30
|
+
export type NodeVerifyResult =
|
|
31
|
+
| { ok: true; envelope: SignedRecordEnvelope; recordId: string }
|
|
32
|
+
| { ok: false; reason: NodeVerifyRejectionReason };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate, signature-check, and content-address a candidate signed record for a
|
|
36
|
+
* v2 hash-chain node.
|
|
37
|
+
*
|
|
38
|
+
* On success the parsed envelope and its `recordId` are returned; the caller
|
|
39
|
+
* (the node app) still enforces owner authority and chain continuity before the
|
|
40
|
+
* record is appended.
|
|
41
|
+
*/
|
|
42
|
+
export async function verifyNodeRecordEnvelope(input: unknown): Promise<NodeVerifyResult> {
|
|
43
|
+
const parsed = signedRecordEnvelopeSchema.safeParse(input);
|
|
44
|
+
if (!parsed.success) {
|
|
45
|
+
return { ok: false, reason: 'invalid_envelope' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const envelope = parsed.data;
|
|
49
|
+
if (envelope.version !== 2) {
|
|
50
|
+
return { ok: false, reason: 'not_v2' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const signatureValid = await verifyEnvelopeSignature(envelope);
|
|
54
|
+
if (!signatureValid) {
|
|
55
|
+
return { ok: false, reason: 'bad_signature' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const recordId = await computeRecordId(envelope);
|
|
59
|
+
return { ok: true, envelope, recordId };
|
|
60
|
+
}
|