@uuaid/pillar 0.2.0-alpha.13 → 0.2.0-alpha.16
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/INTERACTION-CONVENTION.md +192 -0
- package/README.md +275 -11
- package/bin/pillar-blob.mjs +112 -0
- package/bin/pillar-directory.mjs +231 -0
- package/bin/pillar-seed.mjs +112 -13
- package/bin/pillar.mjs +6 -2
- package/package.json +20 -12
- package/src/blob/blob.mjs +426 -0
- package/src/blob/broker.mjs +129 -0
- package/src/blob/http.mjs +196 -0
- package/src/blob/store.mjs +55 -0
- package/src/identity/tier.mjs +24 -3
- package/src/index.mjs +24 -1
- package/src/net/carrier-client.mjs +55 -9
- package/src/net/carrier-server.mjs +169 -11
- package/src/net/delegation.mjs +152 -0
- package/src/net/envelope.mjs +81 -0
- package/src/net/seed-registry.mjs +467 -0
- package/src/net/seed-tier.mjs +185 -0
- package/src/pillar.mjs +96 -3
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blob transport — the oversized-payload path for pillar envelopes.
|
|
3
|
+
*
|
|
4
|
+
* Inline envelopes are capped by the carrier tier ladder (community
|
|
5
|
+
* 512 KiB ... 2 MiB; identity/tier.mjs — those limits are NOT redefined
|
|
6
|
+
* here) and by ABSOLUTE_MAX_BODY as the direct hard cap on ANY send.
|
|
7
|
+
* Above the direct threshold the payload travels out-of-band instead:
|
|
8
|
+
*
|
|
9
|
+
* 1. ENCRYPT locally — a fresh random AES-256-GCM DEK per object and a
|
|
10
|
+
* fresh 12-byte nonce. The GCM AAD binds the ciphertext to the exact
|
|
11
|
+
* envelope that will carry its reference:
|
|
12
|
+
* jcs({ envelopeId, sender, recipient, object, v, expiry })
|
|
13
|
+
* so a ciphertext cannot be transplanted into another envelope, sender,
|
|
14
|
+
* recipient, object name, protocol version, or lifetime.
|
|
15
|
+
* 2. UPLOAD the ciphertext to a blob store (temporary-bucket semantics —
|
|
16
|
+
* objects are ephemeral; expiry travels in the ref and is re-checked
|
|
17
|
+
* on fetch).
|
|
18
|
+
* 3. SEND a signed blob-ref inside the normal sealed envelope. The ref
|
|
19
|
+
* carries DEK + content hash + size; its metadata view (what any
|
|
20
|
+
* carrier-side log/telemetry may ever see) carries none of those.
|
|
21
|
+
* The serialized ref must stay <= 4 KiB.
|
|
22
|
+
* 4. REDEEM on receipt: recipient checks the ref signature + expiry +
|
|
23
|
+
* recipient binding, verifies the sender's registry key is ACTIVE,
|
|
24
|
+
* fetches and hash-checks the object, redeems the broker challenge
|
|
25
|
+
* (single-use), then decrypts.
|
|
26
|
+
*
|
|
27
|
+
* Error contract: every failure throws a typed BlobError subclass with a
|
|
28
|
+
* stable `.code` — blob-too-large, blob-expired, blob-not-found,
|
|
29
|
+
* blob-upload-failed, blob-fetch-failed, blob-integrity-failed,
|
|
30
|
+
* blob-ref-invalid, blob-broker-*, sender-key-not-active.
|
|
31
|
+
*/
|
|
32
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
33
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
34
|
+
import { Keychain } from "../identity/keychain.mjs";
|
|
35
|
+
import { ABSOLUTE_MAX_BODY, TIER_DEFAULTS } from "../identity/tier.mjs";
|
|
36
|
+
|
|
37
|
+
export const BLOB_REF_V = 1;
|
|
38
|
+
export const BLOB_KIND = "blob-ref";
|
|
39
|
+
export const MAX_BLOB_REF_BYTES = 4096; // serialized ref ceiling
|
|
40
|
+
export const MAX_INLINE_BODY_BYTES = ABSOLUTE_MAX_BODY; // 2 MiB direct hard cap
|
|
41
|
+
export const MAX_BLOB_OBJECT_BYTES = 50 * 1024 * 1024; // broker ciphertext cap
|
|
42
|
+
export const MAX_BLOB_PLAINTEXT_BYTES = MAX_BLOB_OBJECT_BYTES - 16; // GCM tag rides with ciphertext
|
|
43
|
+
export const DEFAULT_BLOB_TTL_MS = 24 * 3600 * 1000;
|
|
44
|
+
export const DEFAULT_DIRECT_THRESHOLD_BYTES = 256_000;
|
|
45
|
+
export const NONCE_BYTES = 12;
|
|
46
|
+
export const DEK_BYTES = 32;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------- errors ---
|
|
49
|
+
export class BlobError extends Error {
|
|
50
|
+
constructor(code, message) { super(message ?? code); this.name = "BlobError"; this.code = code; }
|
|
51
|
+
}
|
|
52
|
+
export class BlobSizeError extends BlobError {
|
|
53
|
+
constructor(message, { size, max } = {}) { super("blob-too-large", message); this.size = size; this.max = max; }
|
|
54
|
+
}
|
|
55
|
+
export class BlobExpiryError extends BlobError {
|
|
56
|
+
constructor(message, expiry) { super("blob-expired", message); this.expiry = expiry; }
|
|
57
|
+
}
|
|
58
|
+
export class BlobNotFoundError extends BlobError {
|
|
59
|
+
constructor(object) { super("blob-not-found", `blob object not found: ${object}`); this.object = object; }
|
|
60
|
+
}
|
|
61
|
+
export class BlobUploadError extends BlobError {
|
|
62
|
+
constructor(message, cause = null) { super("blob-upload-failed", message); this.cause = cause; }
|
|
63
|
+
}
|
|
64
|
+
export class BlobFetchError extends BlobError {
|
|
65
|
+
constructor(message, cause = null) { super("blob-fetch-failed", message); this.cause = cause; }
|
|
66
|
+
}
|
|
67
|
+
export class BlobIntegrityError extends BlobError {
|
|
68
|
+
constructor(message) { super("blob-integrity-failed", message); }
|
|
69
|
+
}
|
|
70
|
+
export class BlobRefError extends BlobError {
|
|
71
|
+
constructor(message) { super("blob-ref-invalid", message); }
|
|
72
|
+
}
|
|
73
|
+
export class BlobBrokerError extends BlobError {
|
|
74
|
+
constructor(code, message, status = null) { super(code, message); this.status = status; }
|
|
75
|
+
}
|
|
76
|
+
export class BlobSenderKeyError extends BlobError {
|
|
77
|
+
constructor(message, sender, status) { super("sender-key-not-active", message); this.sender = sender; this.status = status; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ------------------------------------------------------------- crypto -----
|
|
81
|
+
const HEX = { dek: 64, nonce: 24, hash: 64 }; // expected hex lengths
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deterministic AAD: binds one ciphertext to exactly one envelope context.
|
|
85
|
+
* Every field is mandatory — a missing binding is a refusal, not a default.
|
|
86
|
+
*/
|
|
87
|
+
export function blobAad({ envelopeId, sender, recipient, object, v, expiry }) {
|
|
88
|
+
for (const [k, val] of Object.entries({ envelopeId, sender, recipient, object, expiry })) {
|
|
89
|
+
if (typeof val !== "string" || !val) throw new BlobRefError(`blob AAD: field "${k}" required`);
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isInteger(v)) throw new BlobRefError("blob AAD: field \"v\" required");
|
|
92
|
+
return jcs({ envelopeId, sender, recipient, object, v, expiry });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Encrypt plaintext under a fresh per-object AES-256-GCM DEK.
|
|
97
|
+
* Returns Buffers { dek (32 B), nonce (12 B), ct, tag (16 B) }.
|
|
98
|
+
*/
|
|
99
|
+
export function encryptBlob(plaintext, ctx, { nonce = null } = {}) {
|
|
100
|
+
const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
|
|
101
|
+
const aad = blobAad(ctx);
|
|
102
|
+
const dek = randomBytes(DEK_BYTES);
|
|
103
|
+
const nv = nonce ?? randomBytes(NONCE_BYTES);
|
|
104
|
+
if (nv.length !== NONCE_BYTES) throw new BlobRefError(`blob nonce must be ${NONCE_BYTES} bytes`);
|
|
105
|
+
const cipher = createCipheriv("aes-256-gcm", dek, nv);
|
|
106
|
+
cipher.setAAD(Buffer.from(aad, "utf-8"));
|
|
107
|
+
const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
|
|
108
|
+
return { dek, nonce: nv, ct, tag: cipher.getAuthTag() };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Decrypt; throws BlobIntegrityError on ANY AAD/key/tag mismatch.
|
|
113
|
+
* `secret` fields may be Buffers or hex strings.
|
|
114
|
+
*/
|
|
115
|
+
export function decryptBlob({ dek, nonce, ct, tag = null }, ctx) {
|
|
116
|
+
const aad = blobAad(ctx);
|
|
117
|
+
try {
|
|
118
|
+
const stored = Buffer.from(ct, "hex");
|
|
119
|
+
const ciphertext = tag == null ? stored.subarray(0, stored.length - 16) : stored;
|
|
120
|
+
const authTag = tag == null ? stored.subarray(stored.length - 16) : Buffer.from(tag, "hex");
|
|
121
|
+
if (authTag.length !== 16) throw new Error("ciphertext is missing the GCM tag");
|
|
122
|
+
const d = createDecipheriv("aes-256-gcm", Buffer.from(dek, "hex"), Buffer.from(nonce, "hex"));
|
|
123
|
+
d.setAAD(Buffer.from(aad, "utf-8"));
|
|
124
|
+
d.setAuthTag(authTag);
|
|
125
|
+
return Buffer.concat([d.update(ciphertext), d.final()]);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
throw new BlobIntegrityError(`blob integrity check failed: ${e.message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function contentHash(buf) {
|
|
132
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Unguessable upload token for a blob object. Deliberately NOT content-
|
|
137
|
+
* addressed: the AAD must bind the object name before the ciphertext exists
|
|
138
|
+
* (no derivation cycle), and a content digest in the store key would leak a
|
|
139
|
+
* guessable plaintext's fingerprint to the store operator.
|
|
140
|
+
*/
|
|
141
|
+
export function mkObjectName() {
|
|
142
|
+
return randomBytes(32).toString("hex");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ------------------------------------------------------------------ refs ---
|
|
146
|
+
function refSignable(ref) {
|
|
147
|
+
const { sig, ...rest } = ref;
|
|
148
|
+
return jcs(rest);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Sign a blob-ref body with the loaded keychain (sender identity). */
|
|
152
|
+
export function signBlobRef(keychain, ref) {
|
|
153
|
+
if (!keychain?._identity) throw new Error("signBlobRef: keychain not loaded");
|
|
154
|
+
ref.sig = {
|
|
155
|
+
alg: "ed25519",
|
|
156
|
+
publicKey: keychain._identity.publicKeyHex,
|
|
157
|
+
signature: keychain.sign(Buffer.from(refSignable(ref), "utf-8")).toString("hex"),
|
|
158
|
+
};
|
|
159
|
+
return ref;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function verifyBlobRefSig(ref) {
|
|
163
|
+
const sig = ref?.sig;
|
|
164
|
+
if (!sig || sig.alg !== "ed25519") return false;
|
|
165
|
+
if (!/^[0-9a-f]{64}$/i.test(sig.publicKey ?? "") || !/^[0-9a-f]{128}$/i.test(sig.signature ?? "")) return false;
|
|
166
|
+
try {
|
|
167
|
+
return Keychain.verifyDetached(sig.publicKey, Buffer.from(refSignable(ref), "utf-8"), Buffer.from(sig.signature, "hex"));
|
|
168
|
+
} catch (_e) { return false; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Metadata view of a ref — safe for carrier-side logs/telemetry.
|
|
173
|
+
* Deliberately drops DEK, nonce, object id, hash and signature. This view is
|
|
174
|
+
* safe for counters only; the opaque capability never enters logs/telemetry.
|
|
175
|
+
*/
|
|
176
|
+
export function blobRefMetadata(ref) {
|
|
177
|
+
return {
|
|
178
|
+
v: ref.v,
|
|
179
|
+
kind: ref.kind,
|
|
180
|
+
size: ref.size,
|
|
181
|
+
plaintextSize: ref.plaintextSize,
|
|
182
|
+
expiry: ref.expiry,
|
|
183
|
+
createdAt: ref.createdAt,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Secret material of a ref — what only the sealed envelope may carry. */
|
|
188
|
+
export function blobRefSecret(ref) {
|
|
189
|
+
return { dek: ref.dek, nonce: ref.nonce };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function refSerializedBytes(ref) {
|
|
193
|
+
return Buffer.byteLength(JSON.stringify(ref), "utf-8");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Build + encrypt + sign a blob-ref for one plaintext payload.
|
|
198
|
+
* Returns { ref, metadata, ct, ctx }. Ref is rejected if it would
|
|
199
|
+
* serialize above MAX_BLOB_REF_BYTES.
|
|
200
|
+
*/
|
|
201
|
+
export function makeBlobRef(keychain, { envelopeId, recipient, plaintext, ttlMs = DEFAULT_BLOB_TTL_MS, now = Date.now(), challengeId = null, objectId = null, broker = null }) {
|
|
202
|
+
const sender = keychain._identity?.uuaid;
|
|
203
|
+
if (!sender) throw new Error("makeBlobRef: keychain not loaded");
|
|
204
|
+
const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
|
|
205
|
+
const object = objectId ?? mkObjectName();
|
|
206
|
+
const expiry = new Date(now + ttlMs).toISOString();
|
|
207
|
+
const ctx = { envelopeId, sender, recipient, object, v: BLOB_REF_V, expiry };
|
|
208
|
+
const { dek, nonce, ct, tag } = encryptBlob(buf, ctx);
|
|
209
|
+
const stored = Buffer.concat([ct, tag]);
|
|
210
|
+
// Hash covers every STORED byte, including the GCM tag — checkable before decrypt.
|
|
211
|
+
const hash = contentHash(stored);
|
|
212
|
+
const ref = {
|
|
213
|
+
v: BLOB_REF_V,
|
|
214
|
+
kind: BLOB_KIND,
|
|
215
|
+
envelopeId,
|
|
216
|
+
sender,
|
|
217
|
+
recipient,
|
|
218
|
+
object,
|
|
219
|
+
hash,
|
|
220
|
+
size: stored.length,
|
|
221
|
+
plaintextSize: buf.length,
|
|
222
|
+
dek: dek.toString("hex"),
|
|
223
|
+
nonce: nonce.toString("hex"),
|
|
224
|
+
...(broker ? { broker } : {}),
|
|
225
|
+
expiry,
|
|
226
|
+
createdAt: new Date(now).toISOString(),
|
|
227
|
+
...(challengeId ? { challengeId } : {}),
|
|
228
|
+
};
|
|
229
|
+
signBlobRef(keychain, ref);
|
|
230
|
+
const bytes = refSerializedBytes(ref);
|
|
231
|
+
if (bytes > MAX_BLOB_REF_BYTES) {
|
|
232
|
+
throw new BlobRefError(`blob-ref serializes to ${bytes}B > ${MAX_BLOB_REF_BYTES}B ceiling`);
|
|
233
|
+
}
|
|
234
|
+
return { ref, metadata: blobRefMetadata(ref), ct: stored, ctx };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Structural + temporal validation of a received ref. Throws typed errors.
|
|
239
|
+
* `recipient` (when given) must match ref.recipient; `now` injectable for
|
|
240
|
+
* tests.
|
|
241
|
+
*/
|
|
242
|
+
export function validateBlobRef(ref, { recipient = null, now = Date.now() } = {}) {
|
|
243
|
+
if (!ref || typeof ref !== "object") throw new BlobRefError("blob-ref: not an object");
|
|
244
|
+
if (ref.v !== BLOB_REF_V) throw new BlobRefError(`blob-ref: unsupported v ${ref.v}`);
|
|
245
|
+
if (ref.kind !== BLOB_KIND) throw new BlobRefError(`blob-ref: unexpected kind ${ref.kind}`);
|
|
246
|
+
for (const [k, len] of Object.entries(HEX)) {
|
|
247
|
+
if (typeof ref[k] !== "string" || !new RegExp(`^[0-9a-f]{${len}}$`, "i").test(ref[k])) {
|
|
248
|
+
throw new BlobRefError(`blob-ref: bad ${k}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const k of ["envelopeId", "sender", "recipient", "object"]) {
|
|
252
|
+
if (typeof ref[k] !== "string" || !ref[k]) throw new BlobRefError(`blob-ref: missing ${k}`);
|
|
253
|
+
}
|
|
254
|
+
if (!/^[0-9a-f]{64}$/.test(ref.object)) throw new BlobRefError("blob-ref: object must be a 256-bit opaque id");
|
|
255
|
+
if (ref.broker !== undefined) {
|
|
256
|
+
let broker;
|
|
257
|
+
try { broker = new URL(ref.broker); } catch (_error) { throw new BlobRefError("blob-ref: invalid broker URL"); }
|
|
258
|
+
if (broker.protocol !== "https:" && broker.hostname !== "127.0.0.1" && broker.hostname !== "localhost") {
|
|
259
|
+
throw new BlobRefError("blob-ref: broker must use HTTPS");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!Number.isInteger(ref.size) || ref.size <= 16 || ref.size > MAX_BLOB_OBJECT_BYTES) {
|
|
263
|
+
throw new BlobRefError(`blob-ref: bad size ${ref.size}`);
|
|
264
|
+
}
|
|
265
|
+
if (!Number.isInteger(ref.plaintextSize) || ref.plaintextSize <= 0 || ref.plaintextSize !== ref.size - 16) {
|
|
266
|
+
throw new BlobRefError(`blob-ref: bad plaintextSize ${ref.plaintextSize}`);
|
|
267
|
+
}
|
|
268
|
+
const exp = Date.parse(ref.expiry);
|
|
269
|
+
if (!Number.isFinite(exp)) throw new BlobRefError("blob-ref: bad expiry");
|
|
270
|
+
if (exp < now) throw new BlobExpiryError(`blob-ref expired at ${ref.expiry}`, ref.expiry);
|
|
271
|
+
if (!verifyBlobRefSig(ref)) throw new BlobRefError("blob-ref: signature invalid");
|
|
272
|
+
if (recipient && ref.recipient !== recipient) {
|
|
273
|
+
throw new BlobRefError(`blob-ref: addressed to ${ref.recipient}, not ${recipient}`);
|
|
274
|
+
}
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ------------------------------------------------------- size decisions ----
|
|
279
|
+
export function envelopeBodyBytes(envelope) {
|
|
280
|
+
return Buffer.byteLength(JSON.stringify(envelope), "utf-8");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Exact preflight against the carrier limits — tier inline limit first,
|
|
285
|
+
* then the 2 MiB direct hard cap, which no tier grant can raise.
|
|
286
|
+
* Returns { ok, bytes, limit, source: "ok"|"tier"|"direct-cap" }.
|
|
287
|
+
*/
|
|
288
|
+
export function preflightBodyBytes(bodyBytes, { tier = "community", maxBodyBytes = null } = {}) {
|
|
289
|
+
const tierLimit = maxBodyBytes ?? (TIER_DEFAULTS[tier]?.maxBodyBytes ?? TIER_DEFAULTS.community.maxBodyBytes);
|
|
290
|
+
const bytes = Number.isInteger(bodyBytes) ? bodyBytes : -1;
|
|
291
|
+
if (bytes > MAX_INLINE_BODY_BYTES) return { ok: false, bytes, limit: MAX_INLINE_BODY_BYTES, source: "direct-cap" };
|
|
292
|
+
if (bytes > tierLimit) return { ok: false, bytes, limit: tierLimit, source: "tier" };
|
|
293
|
+
return { ok: true, bytes, limit: Math.min(tierLimit, MAX_INLINE_BODY_BYTES), source: "ok" };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Inline-vs-blob decision for a payload of `dataBytes` plaintext. */
|
|
297
|
+
export function chooseTransport(dataBytes, { directThresholdBytes = DEFAULT_DIRECT_THRESHOLD_BYTES } = {}) {
|
|
298
|
+
return dataBytes <= directThresholdBytes ? "inline" : "blob";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ------------------------------------------------------------- flows -------
|
|
302
|
+
/**
|
|
303
|
+
* SEND side (client half). Order is deliberate:
|
|
304
|
+
* broker.reserve -> encrypt+sign ref -> store.put -> broker.finalize
|
|
305
|
+
* An upload failure surfaces as BlobUploadError BEFORE finalize, so a
|
|
306
|
+
* challenge is never finalized for an object that did not land.
|
|
307
|
+
*/
|
|
308
|
+
export async function prepareBlobSend(keychain, { data, envelopeId, recipient, ttlMs = DEFAULT_BLOB_TTL_MS, now = Date.now(), store, broker = null }) {
|
|
309
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
310
|
+
if (buf.length > MAX_BLOB_PLAINTEXT_BYTES) {
|
|
311
|
+
throw new BlobSizeError(`blob payload ${buf.length}B exceeds the ${MAX_BLOB_PLAINTEXT_BYTES}B plaintext cap`, { size: buf.length, max: MAX_BLOB_PLAINTEXT_BYTES });
|
|
312
|
+
}
|
|
313
|
+
if (!store) throw new BlobError("no-store", "prepareBlobSend: a blob store is required");
|
|
314
|
+
let challengeId = null;
|
|
315
|
+
if (broker) {
|
|
316
|
+
const reserved = await broker.reserve({ sender: keychain._identity.uuaid, recipient, size: buf.length + 16 });
|
|
317
|
+
challengeId = reserved.challengeId;
|
|
318
|
+
}
|
|
319
|
+
const { ref, metadata, ct, ctx } = makeBlobRef(keychain, { envelopeId, recipient, plaintext: buf, ttlMs, now, challengeId });
|
|
320
|
+
try {
|
|
321
|
+
await store.put(ref.object, ct);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
if (e instanceof BlobError) throw e;
|
|
324
|
+
throw new BlobUploadError(`blob upload failed for ${ref.object}: ${e.message}`, e);
|
|
325
|
+
}
|
|
326
|
+
if (broker) await broker.finalize({ challengeId, object: ref.object, hash: ref.hash, size: ref.size });
|
|
327
|
+
return { ref, metadata, ctx, size: buf.length, hash: ref.hash, object: ref.object };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* RECEIVE side. Gate order: ref validation (sig/expiry/recipient) ->
|
|
332
|
+
* registry-active sender key check -> fetch -> content-hash check ->
|
|
333
|
+
* broker redemption (single-use, only after every prior gate passed) ->
|
|
334
|
+
* decrypt -> size check.
|
|
335
|
+
*/
|
|
336
|
+
export async function fetchAndDecryptBlob({ ref, store, envelopeId, now = Date.now(), broker = null, senderKeyCheck = null }) {
|
|
337
|
+
validateBlobRef(ref, { recipient: null, now });
|
|
338
|
+
if (typeof ref.envelopeId === "string" && envelopeId && ref.envelopeId !== envelopeId) {
|
|
339
|
+
throw new BlobRefError(`blob-ref bound to envelope ${ref.envelopeId}, received in ${envelopeId}`);
|
|
340
|
+
}
|
|
341
|
+
// Registry-active sender key gate — BEFORE any redemption burns.
|
|
342
|
+
if (senderKeyCheck) {
|
|
343
|
+
const status = await senderKeyCheck(ref.sender, ref.sig?.publicKey);
|
|
344
|
+
if (status !== "active") {
|
|
345
|
+
throw new BlobSenderKeyError(`sender key for ${ref.sender} is ${status ?? "unknown"}, not active — refusing redemption`, ref.sender, status);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
let ct;
|
|
349
|
+
try {
|
|
350
|
+
ct = await store.get(ref.object);
|
|
351
|
+
} catch (e) {
|
|
352
|
+
if (e instanceof BlobError) throw e;
|
|
353
|
+
throw new BlobFetchError(`blob fetch failed for ${ref.object}: ${e.message}`, e);
|
|
354
|
+
}
|
|
355
|
+
if (ct == null) throw new BlobNotFoundError(ref.object);
|
|
356
|
+
if (contentHash(ct) !== ref.hash) {
|
|
357
|
+
throw new BlobIntegrityError(`blob content hash mismatch for ${ref.object}`);
|
|
358
|
+
}
|
|
359
|
+
if (broker && ref.challengeId) await broker.redeem({ challengeId: ref.challengeId });
|
|
360
|
+
const data = decryptBlob({ ...blobRefSecret(ref), ct: Buffer.from(ct).toString("hex") }, {
|
|
361
|
+
envelopeId: ref.envelopeId, sender: ref.sender, recipient: ref.recipient,
|
|
362
|
+
object: ref.object, v: ref.v, expiry: ref.expiry,
|
|
363
|
+
});
|
|
364
|
+
if (data.length !== ref.plaintextSize) {
|
|
365
|
+
throw new BlobIntegrityError(`blob size mismatch: decrypted ${data.length}B, ref says ${ref.plaintextSize}B`);
|
|
366
|
+
}
|
|
367
|
+
return { data, object: ref.object, hash: ref.hash, size: ref.size };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Production send path through the recipient-authenticated UUAID HTTP broker. */
|
|
371
|
+
export async function prepareBrokerBlobSend(keychain, { data, envelopeId, recipient, ttlMs = DEFAULT_BLOB_TTL_MS, now = Date.now(), client }) {
|
|
372
|
+
if (!client) throw new BlobError("no-broker-client", "prepareBrokerBlobSend: broker client required");
|
|
373
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
374
|
+
if (buf.length > MAX_BLOB_PLAINTEXT_BYTES) {
|
|
375
|
+
throw new BlobSizeError(`blob payload ${buf.length}B exceeds the ${MAX_BLOB_PLAINTEXT_BYTES}B plaintext cap`, { size: buf.length, max: MAX_BLOB_PLAINTEXT_BYTES });
|
|
376
|
+
}
|
|
377
|
+
const challenge = await client.challenge("reserve");
|
|
378
|
+
const prepared = makeBlobRef(keychain, {
|
|
379
|
+
envelopeId,
|
|
380
|
+
recipient,
|
|
381
|
+
plaintext: buf,
|
|
382
|
+
ttlMs,
|
|
383
|
+
now,
|
|
384
|
+
objectId: challenge.object_id,
|
|
385
|
+
broker: client.baseUrl,
|
|
386
|
+
});
|
|
387
|
+
const reserved = await client.reserve(challenge, {
|
|
388
|
+
recipient,
|
|
389
|
+
ciphertextSha256: prepared.ref.hash,
|
|
390
|
+
ciphertextBytes: prepared.ref.size,
|
|
391
|
+
expiresAt: prepared.ref.expiry,
|
|
392
|
+
});
|
|
393
|
+
if (reserved.object_id !== prepared.ref.object || reserved.expires_at !== prepared.ref.expiry) {
|
|
394
|
+
throw new BlobIntegrityError("broker reservation does not match the prepared blob-ref");
|
|
395
|
+
}
|
|
396
|
+
await client.upload(reserved.upload, prepared.ct);
|
|
397
|
+
const finalized = await client.finalize(prepared.ref.object);
|
|
398
|
+
client.crossCheckRedemption(prepared.ref, finalized.blob_ref);
|
|
399
|
+
return { ...prepared, size: buf.length, hash: prepared.ref.hash, object: prepared.ref.object };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Production receive path; registry verification precedes broker redemption. */
|
|
403
|
+
export async function fetchBrokerBlob({ ref, envelopeId, recipient, now = Date.now(), client }) {
|
|
404
|
+
if (!client) throw new BlobError("no-broker-client", "fetchBrokerBlob: broker client required");
|
|
405
|
+
validateBlobRef(ref, { recipient, now });
|
|
406
|
+
if (ref.envelopeId !== envelopeId) throw new BlobRefError("blob-ref envelope binding mismatch");
|
|
407
|
+
if (ref.broker !== client.baseUrl) throw new BlobRefError("blob-ref broker binding mismatch");
|
|
408
|
+
await client.assertRegistryActiveSender(ref.sender, ref.sig?.publicKey);
|
|
409
|
+
const redeemed = await client.redeem(ref.object);
|
|
410
|
+
client.crossCheckRedemption(ref, redeemed.blob);
|
|
411
|
+
const stored = await client.download(redeemed.download);
|
|
412
|
+
if (stored.length !== ref.size) throw new BlobIntegrityError(`blob ciphertext size mismatch: ${stored.length}B != ${ref.size}B`);
|
|
413
|
+
if (contentHash(stored) !== ref.hash) throw new BlobIntegrityError("blob ciphertext hash mismatch");
|
|
414
|
+
const data = decryptBlob({ ...blobRefSecret(ref), ct: stored.toString("hex") }, {
|
|
415
|
+
envelopeId: ref.envelopeId,
|
|
416
|
+
sender: ref.sender,
|
|
417
|
+
recipient: ref.recipient,
|
|
418
|
+
object: ref.object,
|
|
419
|
+
v: ref.v,
|
|
420
|
+
expiry: ref.expiry,
|
|
421
|
+
});
|
|
422
|
+
if (data.length !== ref.plaintextSize) {
|
|
423
|
+
throw new BlobIntegrityError(`blob plaintext size mismatch: ${data.length}B != ${ref.plaintextSize}B`);
|
|
424
|
+
}
|
|
425
|
+
return { data, object: ref.object, hash: ref.hash, size: ref.plaintextSize };
|
|
426
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blob challenge broker — single-use redemption tickets for blob transport.
|
|
3
|
+
*
|
|
4
|
+
* Flow: reserve (sender announces an upload) -> finalize (upload landed,
|
|
5
|
+
* challenge is armed) -> redeem (recipient burns it exactly once, and only
|
|
6
|
+
* after the registry-active sender key check passed). A challenge that was
|
|
7
|
+
* never finalized, was already redeemed, or whose holder key is no longer
|
|
8
|
+
* active is refused — the redeem counter is the anti-replay guard.
|
|
9
|
+
*
|
|
10
|
+
* DEV/TEST ONLY. Production uses PillarBlobHttpClient with the UUAID broker;
|
|
11
|
+
* this in-memory implementation exists for deterministic local tests.
|
|
12
|
+
*/
|
|
13
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { BlobBrokerError } from "./blob.mjs";
|
|
16
|
+
|
|
17
|
+
export class InMemoryChallengeBroker {
|
|
18
|
+
constructor({ reserveTtlMs = 5 * 60 * 1000, now = () => Date.now() } = {}) {
|
|
19
|
+
this.reserveTtlMs = reserveTtlMs;
|
|
20
|
+
this.now = now;
|
|
21
|
+
this.challenges = new Map(); // challengeId -> record
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Step 1: sender announces an upload; the challenge starts un-finalized. */
|
|
25
|
+
async reserve({ sender, recipient, size }) {
|
|
26
|
+
if (!sender) throw new BlobBrokerError("blob-broker-bad-request", "reserve: sender required");
|
|
27
|
+
if (!Number.isInteger(size) || size <= 0) throw new BlobBrokerError("blob-broker-bad-request", "reserve: size required");
|
|
28
|
+
const challengeId = `blob-ch-${this.now().toString(36)}-${randomBytes(8).toString("hex")}`;
|
|
29
|
+
this.challenges.set(challengeId, {
|
|
30
|
+
sender, recipient: recipient ?? null, size,
|
|
31
|
+
reservedAt: this.now(), expiresAt: this.now() + this.reserveTtlMs,
|
|
32
|
+
state: "reserved", object: null, hash: null, redeemedAt: null,
|
|
33
|
+
});
|
|
34
|
+
return { challengeId, expiresAt: this.now() + this.reserveTtlMs };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Step 2: the upload landed — arm the challenge for redemption. */
|
|
38
|
+
async finalize({ challengeId, object, hash, size }) {
|
|
39
|
+
const rec = this.challenges.get(challengeId);
|
|
40
|
+
if (!rec) throw new BlobBrokerError("blob-broker-not-found", `finalize: unknown challenge ${challengeId}`, 404);
|
|
41
|
+
if (rec.state !== "reserved") throw new BlobBrokerError("blob-broker-bad-state", `finalize: challenge is ${rec.state}`);
|
|
42
|
+
if (typeof object !== "string" || !object) throw new BlobBrokerError("blob-broker-bad-request", "finalize: object required");
|
|
43
|
+
if (typeof hash !== "string" || !/^[0-9a-f]{64}$/i.test(hash)) throw new BlobBrokerError("blob-broker-bad-request", "finalize: hash required");
|
|
44
|
+
if (!Number.isInteger(size) || size <= 0 || size !== rec.size) {
|
|
45
|
+
throw new BlobBrokerError("blob-broker-bad-request", `finalize: size ${size} != reserved ${rec.size}`);
|
|
46
|
+
}
|
|
47
|
+
rec.object = object; rec.hash = hash.toLowerCase(); rec.state = "finalized"; rec.finalizedAt = this.now();
|
|
48
|
+
return { challengeId, state: rec.state };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Step 3: single-use redemption. Throws on replay, expiry, bad state. */
|
|
52
|
+
async redeem({ challengeId, object = null, hash = null, sender = null, now = null }) {
|
|
53
|
+
const rec = this.challenges.get(challengeId);
|
|
54
|
+
if (!rec) throw new BlobBrokerError("blob-broker-not-found", `redeem: unknown challenge ${challengeId}`, 404);
|
|
55
|
+
if (rec.state === "redeemed") {
|
|
56
|
+
throw new BlobBrokerError("blob-broker-already-redeemed", `challenge ${challengeId} already redeemed at ${rec.redeemedAt}`, 409);
|
|
57
|
+
}
|
|
58
|
+
if (rec.state !== "finalized") {
|
|
59
|
+
throw new BlobBrokerError("blob-broker-not-finalized", `challenge ${challengeId} never finalized (state ${rec.state})`, 409);
|
|
60
|
+
}
|
|
61
|
+
const t = now ?? this.now();
|
|
62
|
+
if (rec.expiresAt < t) {
|
|
63
|
+
throw new BlobBrokerError("blob-broker-expired", `challenge ${challengeId} expired at ${new Date(rec.expiresAt).toISOString()}`, 410);
|
|
64
|
+
}
|
|
65
|
+
if (object && object !== rec.object) throw new BlobBrokerError("blob-broker-mismatch", "redeem: object mismatch", 400);
|
|
66
|
+
if (hash && hash.toLowerCase() !== rec.hash) throw new BlobBrokerError("blob-broker-mismatch", "redeem: hash mismatch", 400);
|
|
67
|
+
if (sender && sender !== rec.sender) throw new BlobBrokerError("blob-broker-mismatch", "redeem: sender mismatch", 400);
|
|
68
|
+
rec.state = "redeemed"; rec.redeemedAt = t;
|
|
69
|
+
return { challengeId, redeemedAt: t, object: rec.object, sender: rec.sender };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Inspection only — never a substitute for redeem(). */
|
|
73
|
+
state(challengeId) { return this.challenges.get(challengeId) ?? null; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Append-only JSONL journal on top of the in-memory broker. The journal IS
|
|
78
|
+
* the persistence: construction replays it, so a CLI `put` in one process and
|
|
79
|
+
* `get` (redemption) in another share the same challenge ledger.
|
|
80
|
+
*/
|
|
81
|
+
export class JournalingBlobBroker extends InMemoryChallengeBroker {
|
|
82
|
+
constructor({ journalPath = null, ...rest } = {}) {
|
|
83
|
+
super(rest);
|
|
84
|
+
this.journalPath = journalPath;
|
|
85
|
+
this.journal = [];
|
|
86
|
+
if (journalPath && existsSync(journalPath)) this._replay(journalPath);
|
|
87
|
+
}
|
|
88
|
+
_replay(path) {
|
|
89
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
90
|
+
if (!line.trim()) continue;
|
|
91
|
+
let e;
|
|
92
|
+
try { e = JSON.parse(line); } catch (_err) { continue; }
|
|
93
|
+
this.journal.push(e);
|
|
94
|
+
const cid = e.challengeId;
|
|
95
|
+
if (e.op === "reserve") {
|
|
96
|
+
this.challenges.set(cid, {
|
|
97
|
+
sender: e.sender, recipient: e.recipient ?? null, size: e.size,
|
|
98
|
+
reservedAt: e.at, expiresAt: e.expiresAt,
|
|
99
|
+
state: "reserved", object: null, hash: null, redeemedAt: null,
|
|
100
|
+
});
|
|
101
|
+
} else if (e.op === "finalize") {
|
|
102
|
+
const rec = this.challenges.get(cid);
|
|
103
|
+
if (rec) { rec.object = e.object; rec.hash = e.hash; rec.state = "finalized"; rec.finalizedAt = e.at; }
|
|
104
|
+
} else if (e.op === "redeem") {
|
|
105
|
+
const rec = this.challenges.get(cid);
|
|
106
|
+
if (rec) { rec.state = "redeemed"; rec.redeemedAt = e.at; }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async reserve(o) {
|
|
111
|
+
const r = await super.reserve(o);
|
|
112
|
+
this._log({ op: "reserve", challengeId: r.challengeId, sender: o.sender, recipient: o.recipient ?? null, size: o.size, expiresAt: r.expiresAt, at: this.now() });
|
|
113
|
+
return r;
|
|
114
|
+
}
|
|
115
|
+
async finalize(o) {
|
|
116
|
+
const r = await super.finalize(o);
|
|
117
|
+
this._log({ op: "finalize", challengeId: o.challengeId, object: o.object, hash: o.hash, at: this.now() });
|
|
118
|
+
return r;
|
|
119
|
+
}
|
|
120
|
+
async redeem(o) {
|
|
121
|
+
const r = await super.redeem(o);
|
|
122
|
+
this._log({ op: "redeem", challengeId: o.challengeId, sender: r.sender, at: r.redeemedAt });
|
|
123
|
+
return r;
|
|
124
|
+
}
|
|
125
|
+
_log(entry) {
|
|
126
|
+
this.journal.push(entry);
|
|
127
|
+
if (this.journalPath) appendFileSync(this.journalPath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
128
|
+
}
|
|
129
|
+
}
|