@uuaid/pillar 0.2.0-alpha.8 → 2.0.1
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/HOSTING-LIMITS.md +432 -0
- package/INTERACTION-CONVENTION.md +222 -1
- package/README.md +371 -17
- package/bin/carrier.mjs +7 -1
- package/bin/pillar-ask.mjs +1 -0
- package/bin/pillar-blob.mjs +139 -0
- package/bin/pillar-directory.mjs +232 -0
- package/bin/pillar-escrow.mjs +675 -0
- package/bin/pillar-responder.mjs +47 -1
- package/bin/pillar-seed.mjs +113 -13
- package/bin/pillar.mjs +345 -4
- package/package.json +30 -7
- package/src/blob/blob.mjs +510 -0
- package/src/blob/broker.mjs +129 -0
- package/src/blob/http.mjs +334 -0
- package/src/blob/store.mjs +55 -0
- package/src/escrow/box.mjs +43 -0
- package/src/escrow/escrow-service.mjs +538 -0
- package/src/escrow/rails/evm-rail.mjs +427 -0
- package/src/escrow/rails/index.mjs +41 -0
- package/src/escrow/rails/zilligon-rail.mjs +73 -0
- package/src/escrow/terms.mjs +368 -0
- package/src/escrow/token-registry.mjs +231 -0
- package/src/escrow/verify.mjs +119 -0
- package/src/escrow/zilligon.mjs +266 -0
- package/src/identity/keychain.mjs +27 -10
- package/src/identity/tier.mjs +130 -0
- package/src/identity/trust.mjs +63 -0
- package/src/index.mjs +28 -2
- package/src/mailbox/mailbox.mjs +41 -0
- package/src/net/carrier-client.mjs +93 -14
- package/src/net/carrier-server.mjs +212 -15
- package/src/net/delegation.mjs +209 -0
- package/src/net/envelope.mjs +81 -0
- package/src/net/link-offer.mjs +107 -0
- package/src/net/refusals.mjs +126 -0
- package/src/net/seed-registry.mjs +467 -0
- package/src/net/seed-tier.mjs +185 -0
- package/src/node-check.mjs +32 -0
- package/src/observability.mjs +141 -0
- package/src/pillar.mjs +206 -17
- package/src/vda/core.mjs +290 -0
- package/src/vda/directory.mjs +87 -0
- package/src/vda/instruments.mjs +246 -0
- package/src/vda/shims.mjs +77 -0
|
@@ -0,0 +1,510 @@
|
|
|
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, proves the sender (its UUAID derives from the key
|
|
24
|
+
* that signed the ref, or else a registry badge names that key),
|
|
25
|
+
* fetches and hash-checks the object, redeems the broker challenge
|
|
26
|
+
* (single-use), then decrypts.
|
|
27
|
+
*
|
|
28
|
+
* Error contract: every failure throws a typed BlobError subclass with a
|
|
29
|
+
* stable `.code` — blob-too-large, blob-expired, blob-not-found,
|
|
30
|
+
* blob-upload-failed, blob-fetch-failed, blob-integrity-failed,
|
|
31
|
+
* blob-ref-invalid, blob-broker-*, blob-key-unproven, sender-key-not-active.
|
|
32
|
+
* A broker refusal carries the broker's own `.code` and `.fix` when the
|
|
33
|
+
* broker sends them (`.legacyCode` keeps the blob-broker-* value). A refusal
|
|
34
|
+
* that knows its next step has a one-line `.fix`.
|
|
35
|
+
*/
|
|
36
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
37
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
38
|
+
import { Keychain, localIdFromKey } from "../identity/keychain.mjs";
|
|
39
|
+
import { ABSOLUTE_MAX_BODY, TIER_DEFAULTS } from "../identity/tier.mjs";
|
|
40
|
+
import { REFUSAL_FIXES } from "../net/refusals.mjs";
|
|
41
|
+
|
|
42
|
+
export const BLOB_REF_V = 1;
|
|
43
|
+
export const BLOB_KIND = "blob-ref";
|
|
44
|
+
export const MAX_BLOB_REF_BYTES = 4096; // serialized ref ceiling
|
|
45
|
+
export const MAX_INLINE_BODY_BYTES = ABSOLUTE_MAX_BODY; // 2 MiB direct hard cap
|
|
46
|
+
export const MAX_BLOB_OBJECT_BYTES = 50 * 1024 * 1024; // broker ciphertext cap
|
|
47
|
+
export const MAX_BLOB_PLAINTEXT_BYTES = MAX_BLOB_OBJECT_BYTES - 16; // GCM tag rides with ciphertext
|
|
48
|
+
export const DEFAULT_BLOB_TTL_MS = 24 * 3600 * 1000;
|
|
49
|
+
export const DEFAULT_DIRECT_THRESHOLD_BYTES = 256_000;
|
|
50
|
+
export const NONCE_BYTES = 12;
|
|
51
|
+
export const DEK_BYTES = 32;
|
|
52
|
+
/** keyId a broker request presents when the identity proves itself by its own key. */
|
|
53
|
+
export const SELF_CERTIFIED_KEY_ID = "self-certified:ed25519";
|
|
54
|
+
/** The one UUAID namespace and object type the broker accepts on the self-certified path. */
|
|
55
|
+
export const SELF_CERTIFIED_NAMESPACE = "foundation";
|
|
56
|
+
export const SELF_CERTIFIED_OBJECT_TYPE = "agent";
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------- errors ---
|
|
59
|
+
export class BlobError extends Error {
|
|
60
|
+
constructor(code, message, { fix = null } = {}) {
|
|
61
|
+
super(message ?? code); this.name = "BlobError"; this.code = code;
|
|
62
|
+
if (fix) this.fix = fix;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export class BlobSizeError extends BlobError {
|
|
66
|
+
constructor(message, { size, max } = {}) { super("blob-too-large", message); this.size = size; this.max = max; }
|
|
67
|
+
}
|
|
68
|
+
export class BlobExpiryError extends BlobError {
|
|
69
|
+
constructor(message, expiry) { super("blob-expired", message); this.expiry = expiry; }
|
|
70
|
+
}
|
|
71
|
+
export class BlobNotFoundError extends BlobError {
|
|
72
|
+
constructor(object) { super("blob-not-found", `blob object not found: ${object}`); this.object = object; }
|
|
73
|
+
}
|
|
74
|
+
export class BlobUploadError extends BlobError {
|
|
75
|
+
constructor(message, cause = null) { super("blob-upload-failed", message); this.cause = cause; }
|
|
76
|
+
}
|
|
77
|
+
export class BlobFetchError extends BlobError {
|
|
78
|
+
constructor(message, cause = null) { super("blob-fetch-failed", message); this.cause = cause; }
|
|
79
|
+
}
|
|
80
|
+
export class BlobIntegrityError extends BlobError {
|
|
81
|
+
constructor(message) { super("blob-integrity-failed", message); }
|
|
82
|
+
}
|
|
83
|
+
export class BlobRefError extends BlobError {
|
|
84
|
+
constructor(message) { super("blob-ref-invalid", message); }
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* `code` is the broker's own code when it sent one; `legacyCode` is the
|
|
88
|
+
* status-derived blob-broker-* code this client has always used (set on HTTP
|
|
89
|
+
* refusals); `body` is the refusal body, numbers included.
|
|
90
|
+
*/
|
|
91
|
+
export class BlobBrokerError extends BlobError {
|
|
92
|
+
constructor(code, message, status = null, { fix = null, legacyCode = null, body = null } = {}) {
|
|
93
|
+
super(code, message, { fix });
|
|
94
|
+
this.status = status;
|
|
95
|
+
if (legacyCode) this.legacyCode = legacyCode;
|
|
96
|
+
if (body) this.body = body;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export class BlobSenderKeyError extends BlobError {
|
|
100
|
+
constructor(message, sender, status) {
|
|
101
|
+
super("sender-key-not-active", message, { fix: REFUSAL_FIXES["sender-key-not-active"] });
|
|
102
|
+
this.sender = sender; this.status = status;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ------------------------------------------------------------- crypto -----
|
|
107
|
+
const HEX = { dek: 64, nonce: 24, hash: 64 }; // expected hex lengths
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Deterministic AAD: binds one ciphertext to exactly one envelope context.
|
|
111
|
+
* Every field is mandatory — a missing binding is a refusal, not a default.
|
|
112
|
+
*/
|
|
113
|
+
export function blobAad({ envelopeId, sender, recipient, object, v, expiry }) {
|
|
114
|
+
for (const [k, val] of Object.entries({ envelopeId, sender, recipient, object, expiry })) {
|
|
115
|
+
if (typeof val !== "string" || !val) throw new BlobRefError(`blob AAD: field "${k}" required`);
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isInteger(v)) throw new BlobRefError("blob AAD: field \"v\" required");
|
|
118
|
+
return jcs({ envelopeId, sender, recipient, object, v, expiry });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Encrypt plaintext under a fresh per-object AES-256-GCM DEK.
|
|
123
|
+
* Returns Buffers { dek (32 B), nonce (12 B), ct, tag (16 B) }.
|
|
124
|
+
*/
|
|
125
|
+
export function encryptBlob(plaintext, ctx, { nonce = null } = {}) {
|
|
126
|
+
const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
|
|
127
|
+
const aad = blobAad(ctx);
|
|
128
|
+
const dek = randomBytes(DEK_BYTES);
|
|
129
|
+
const nv = nonce ?? randomBytes(NONCE_BYTES);
|
|
130
|
+
if (nv.length !== NONCE_BYTES) throw new BlobRefError(`blob nonce must be ${NONCE_BYTES} bytes`);
|
|
131
|
+
const cipher = createCipheriv("aes-256-gcm", dek, nv);
|
|
132
|
+
cipher.setAAD(Buffer.from(aad, "utf-8"));
|
|
133
|
+
const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
|
|
134
|
+
return { dek, nonce: nv, ct, tag: cipher.getAuthTag() };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Decrypt; throws BlobIntegrityError on ANY AAD/key/tag mismatch.
|
|
139
|
+
* `secret` fields may be Buffers or hex strings.
|
|
140
|
+
*/
|
|
141
|
+
export function decryptBlob({ dek, nonce, ct, tag = null }, ctx) {
|
|
142
|
+
const aad = blobAad(ctx);
|
|
143
|
+
try {
|
|
144
|
+
const stored = Buffer.from(ct, "hex");
|
|
145
|
+
const ciphertext = tag == null ? stored.subarray(0, stored.length - 16) : stored;
|
|
146
|
+
const authTag = tag == null ? stored.subarray(stored.length - 16) : Buffer.from(tag, "hex");
|
|
147
|
+
if (authTag.length !== 16) throw new Error("ciphertext is missing the GCM tag");
|
|
148
|
+
const d = createDecipheriv("aes-256-gcm", Buffer.from(dek, "hex"), Buffer.from(nonce, "hex"));
|
|
149
|
+
d.setAAD(Buffer.from(aad, "utf-8"));
|
|
150
|
+
d.setAuthTag(authTag);
|
|
151
|
+
return Buffer.concat([d.update(ciphertext), d.final()]);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
throw new BlobIntegrityError(`blob integrity check failed: ${e.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function contentHash(buf) {
|
|
158
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Unguessable upload token for a blob object. Deliberately NOT content-
|
|
163
|
+
* addressed: the AAD must bind the object name before the ciphertext exists
|
|
164
|
+
* (no derivation cycle), and a content digest in the store key would leak a
|
|
165
|
+
* guessable plaintext's fingerprint to the store operator.
|
|
166
|
+
*/
|
|
167
|
+
export function mkObjectName() {
|
|
168
|
+
return randomBytes(32).toString("hex");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ------------------------------------------------------ self-certified ---
|
|
172
|
+
/**
|
|
173
|
+
* The one UUAID a key self-certifies, rebuilt in full:
|
|
174
|
+
* `uuaid:foundation:agent:` + sha256(publicKey)[:16] as a UUID (localIdFromKey,
|
|
175
|
+
* the twin of pillar-client's localIdFromPublicKey). Null for a malformed key.
|
|
176
|
+
*/
|
|
177
|
+
export function selfCertifiedUuaidFor(publicKeyHex) {
|
|
178
|
+
if (typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return null;
|
|
179
|
+
const localId = localIdFromKey(Buffer.from(publicKeyHex, "hex"));
|
|
180
|
+
return `uuaid:${SELF_CERTIFIED_NAMESPACE}:${SELF_CERTIFIED_OBJECT_TYPE}:${localId}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* True only when `uuaid` is EXACTLY the UUAID the key self-certifies (full
|
|
185
|
+
* string, namespace and object type included, the broker's rule); such an
|
|
186
|
+
* identity uses the broker without a registry badge. A device, a service,
|
|
187
|
+
* another namespace, or a matching local id under any other prefix is not
|
|
188
|
+
* self-certified: it needs a registry badge naming its key.
|
|
189
|
+
*/
|
|
190
|
+
export function isSelfCertifiedUuaid(uuaid, publicKeyHex) {
|
|
191
|
+
const expected = selfCertifiedUuaidFor(publicKeyHex);
|
|
192
|
+
return expected !== null && uuaid === expected;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Recipient sender gate, self-certified half: the ref's signature verifies
|
|
197
|
+
* under `ref.sig.publicKey`, and `ref.sender` derives from that same key.
|
|
198
|
+
* The sender field is inside the signed bytes, so the key that proves the
|
|
199
|
+
* UUAID is the key whose signature was checked. Checked here rather than
|
|
200
|
+
* trusted from an earlier validateBlobRef, so the gate stands on its own.
|
|
201
|
+
*/
|
|
202
|
+
export function senderSelfCertified(ref) {
|
|
203
|
+
return verifyBlobRefSig(ref) && isSelfCertifiedUuaid(ref?.sender, ref?.sig?.publicKey);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ------------------------------------------------------------------ refs ---
|
|
207
|
+
function refSignable(ref) {
|
|
208
|
+
const { sig, ...rest } = ref;
|
|
209
|
+
return jcs(rest);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Sign a blob-ref body with the loaded keychain (sender identity). */
|
|
213
|
+
export function signBlobRef(keychain, ref) {
|
|
214
|
+
if (!keychain?._identity) throw new Error("signBlobRef: keychain not loaded");
|
|
215
|
+
ref.sig = {
|
|
216
|
+
alg: "ed25519",
|
|
217
|
+
publicKey: keychain._identity.publicKeyHex,
|
|
218
|
+
signature: keychain.sign(Buffer.from(refSignable(ref), "utf-8")).toString("hex"),
|
|
219
|
+
};
|
|
220
|
+
return ref;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function verifyBlobRefSig(ref) {
|
|
224
|
+
const sig = ref?.sig;
|
|
225
|
+
if (!sig || sig.alg !== "ed25519") return false;
|
|
226
|
+
if (!/^[0-9a-f]{64}$/i.test(sig.publicKey ?? "") || !/^[0-9a-f]{128}$/i.test(sig.signature ?? "")) return false;
|
|
227
|
+
try {
|
|
228
|
+
return Keychain.verifyDetached(sig.publicKey, Buffer.from(refSignable(ref), "utf-8"), Buffer.from(sig.signature, "hex"));
|
|
229
|
+
} catch (_e) { return false; }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Metadata view of a ref — safe for carrier-side logs/telemetry.
|
|
234
|
+
* Deliberately drops DEK, nonce, object id, hash and signature. This view is
|
|
235
|
+
* safe for counters only; the opaque capability never enters logs/telemetry.
|
|
236
|
+
*/
|
|
237
|
+
export function blobRefMetadata(ref) {
|
|
238
|
+
return {
|
|
239
|
+
v: ref.v,
|
|
240
|
+
kind: ref.kind,
|
|
241
|
+
size: ref.size,
|
|
242
|
+
plaintextSize: ref.plaintextSize,
|
|
243
|
+
expiry: ref.expiry,
|
|
244
|
+
createdAt: ref.createdAt,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Secret material of a ref — what only the sealed envelope may carry. */
|
|
249
|
+
export function blobRefSecret(ref) {
|
|
250
|
+
return { dek: ref.dek, nonce: ref.nonce };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function refSerializedBytes(ref) {
|
|
254
|
+
return Buffer.byteLength(JSON.stringify(ref), "utf-8");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Build + encrypt + sign a blob-ref for one plaintext payload.
|
|
259
|
+
* Returns { ref, metadata, ct, ctx }. Ref is rejected if it would
|
|
260
|
+
* serialize above MAX_BLOB_REF_BYTES.
|
|
261
|
+
*/
|
|
262
|
+
export function makeBlobRef(keychain, { envelopeId, recipient, plaintext, ttlMs = DEFAULT_BLOB_TTL_MS, now = Date.now(), challengeId = null, objectId = null, broker = null }) {
|
|
263
|
+
const sender = keychain._identity?.uuaid;
|
|
264
|
+
if (!sender) throw new Error("makeBlobRef: keychain not loaded");
|
|
265
|
+
const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
|
|
266
|
+
const object = objectId ?? mkObjectName();
|
|
267
|
+
const expiry = new Date(now + ttlMs).toISOString();
|
|
268
|
+
const ctx = { envelopeId, sender, recipient, object, v: BLOB_REF_V, expiry };
|
|
269
|
+
const { dek, nonce, ct, tag } = encryptBlob(buf, ctx);
|
|
270
|
+
const stored = Buffer.concat([ct, tag]);
|
|
271
|
+
// Hash covers every STORED byte, including the GCM tag — checkable before decrypt.
|
|
272
|
+
const hash = contentHash(stored);
|
|
273
|
+
const ref = {
|
|
274
|
+
v: BLOB_REF_V,
|
|
275
|
+
kind: BLOB_KIND,
|
|
276
|
+
envelopeId,
|
|
277
|
+
sender,
|
|
278
|
+
recipient,
|
|
279
|
+
object,
|
|
280
|
+
hash,
|
|
281
|
+
size: stored.length,
|
|
282
|
+
plaintextSize: buf.length,
|
|
283
|
+
dek: dek.toString("hex"),
|
|
284
|
+
nonce: nonce.toString("hex"),
|
|
285
|
+
...(broker ? { broker } : {}),
|
|
286
|
+
expiry,
|
|
287
|
+
createdAt: new Date(now).toISOString(),
|
|
288
|
+
...(challengeId ? { challengeId } : {}),
|
|
289
|
+
};
|
|
290
|
+
signBlobRef(keychain, ref);
|
|
291
|
+
const bytes = refSerializedBytes(ref);
|
|
292
|
+
if (bytes > MAX_BLOB_REF_BYTES) {
|
|
293
|
+
throw new BlobRefError(`blob-ref serializes to ${bytes}B > ${MAX_BLOB_REF_BYTES}B ceiling`);
|
|
294
|
+
}
|
|
295
|
+
return { ref, metadata: blobRefMetadata(ref), ct: stored, ctx };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Structural + temporal validation of a received ref. Throws typed errors.
|
|
300
|
+
* `recipient` (when given) must match ref.recipient; `now` injectable for
|
|
301
|
+
* tests.
|
|
302
|
+
*/
|
|
303
|
+
export function validateBlobRef(ref, { recipient = null, now = Date.now() } = {}) {
|
|
304
|
+
if (!ref || typeof ref !== "object") throw new BlobRefError("blob-ref: not an object");
|
|
305
|
+
if (ref.v !== BLOB_REF_V) throw new BlobRefError(`blob-ref: unsupported v ${ref.v}`);
|
|
306
|
+
if (ref.kind !== BLOB_KIND) throw new BlobRefError(`blob-ref: unexpected kind ${ref.kind}`);
|
|
307
|
+
for (const [k, len] of Object.entries(HEX)) {
|
|
308
|
+
if (typeof ref[k] !== "string" || !new RegExp(`^[0-9a-f]{${len}}$`, "i").test(ref[k])) {
|
|
309
|
+
throw new BlobRefError(`blob-ref: bad ${k}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const k of ["envelopeId", "sender", "recipient", "object"]) {
|
|
313
|
+
if (typeof ref[k] !== "string" || !ref[k]) throw new BlobRefError(`blob-ref: missing ${k}`);
|
|
314
|
+
}
|
|
315
|
+
if (!/^[0-9a-f]{64}$/.test(ref.object)) throw new BlobRefError("blob-ref: object must be a 256-bit opaque id");
|
|
316
|
+
if (ref.broker !== undefined) {
|
|
317
|
+
let broker;
|
|
318
|
+
try { broker = new URL(ref.broker); } catch (_error) { throw new BlobRefError("blob-ref: invalid broker URL"); }
|
|
319
|
+
if (broker.protocol !== "https:" && broker.hostname !== "127.0.0.1" && broker.hostname !== "localhost") {
|
|
320
|
+
throw new BlobRefError("blob-ref: broker must use HTTPS");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!Number.isInteger(ref.size) || ref.size <= 16 || ref.size > MAX_BLOB_OBJECT_BYTES) {
|
|
324
|
+
throw new BlobRefError(`blob-ref: bad size ${ref.size}`);
|
|
325
|
+
}
|
|
326
|
+
if (!Number.isInteger(ref.plaintextSize) || ref.plaintextSize <= 0 || ref.plaintextSize !== ref.size - 16) {
|
|
327
|
+
throw new BlobRefError(`blob-ref: bad plaintextSize ${ref.plaintextSize}`);
|
|
328
|
+
}
|
|
329
|
+
const exp = Date.parse(ref.expiry);
|
|
330
|
+
if (!Number.isFinite(exp)) throw new BlobRefError("blob-ref: bad expiry");
|
|
331
|
+
if (exp < now) throw new BlobExpiryError(`blob-ref expired at ${ref.expiry}`, ref.expiry);
|
|
332
|
+
if (!verifyBlobRefSig(ref)) throw new BlobRefError("blob-ref: signature invalid");
|
|
333
|
+
if (recipient && ref.recipient !== recipient) {
|
|
334
|
+
throw new BlobRefError(`blob-ref: addressed to ${ref.recipient}, not ${recipient}`);
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ------------------------------------------------------- size decisions ----
|
|
340
|
+
export function envelopeBodyBytes(envelope) {
|
|
341
|
+
return Buffer.byteLength(JSON.stringify(envelope), "utf-8");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Exact preflight against the carrier limits — tier inline limit first,
|
|
346
|
+
* then the 2 MiB direct hard cap, which no tier grant can raise.
|
|
347
|
+
* Returns { ok, bytes, limit, source: "ok"|"tier"|"direct-cap" }.
|
|
348
|
+
*/
|
|
349
|
+
export function preflightBodyBytes(bodyBytes, { tier = "community", maxBodyBytes = null } = {}) {
|
|
350
|
+
const tierLimit = maxBodyBytes ?? (TIER_DEFAULTS[tier]?.maxBodyBytes ?? TIER_DEFAULTS.community.maxBodyBytes);
|
|
351
|
+
const bytes = Number.isInteger(bodyBytes) ? bodyBytes : -1;
|
|
352
|
+
if (bytes > MAX_INLINE_BODY_BYTES) return { ok: false, bytes, limit: MAX_INLINE_BODY_BYTES, source: "direct-cap" };
|
|
353
|
+
if (bytes > tierLimit) return { ok: false, bytes, limit: tierLimit, source: "tier" };
|
|
354
|
+
return { ok: true, bytes, limit: Math.min(tierLimit, MAX_INLINE_BODY_BYTES), source: "ok" };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Inline-vs-blob decision for a payload of `dataBytes` plaintext. */
|
|
358
|
+
export function chooseTransport(dataBytes, { directThresholdBytes = DEFAULT_DIRECT_THRESHOLD_BYTES } = {}) {
|
|
359
|
+
return dataBytes <= directThresholdBytes ? "inline" : "blob";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ------------------------------------------------------------- flows -------
|
|
363
|
+
/**
|
|
364
|
+
* SEND side (client half). Order is deliberate:
|
|
365
|
+
* broker.reserve -> encrypt+sign ref -> store.put -> broker.finalize
|
|
366
|
+
* An upload failure surfaces as BlobUploadError BEFORE finalize, so a
|
|
367
|
+
* challenge is never finalized for an object that did not land.
|
|
368
|
+
*/
|
|
369
|
+
export async function prepareBlobSend(keychain, { data, envelopeId, recipient, ttlMs = DEFAULT_BLOB_TTL_MS, now = Date.now(), store, broker = null }) {
|
|
370
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
371
|
+
if (buf.length > MAX_BLOB_PLAINTEXT_BYTES) {
|
|
372
|
+
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 });
|
|
373
|
+
}
|
|
374
|
+
if (!store) throw new BlobError("no-store", "prepareBlobSend: a blob store is required");
|
|
375
|
+
let challengeId = null;
|
|
376
|
+
if (broker) {
|
|
377
|
+
const reserved = await broker.reserve({ sender: keychain._identity.uuaid, recipient, size: buf.length + 16 });
|
|
378
|
+
challengeId = reserved.challengeId;
|
|
379
|
+
}
|
|
380
|
+
const { ref, metadata, ct, ctx } = makeBlobRef(keychain, { envelopeId, recipient, plaintext: buf, ttlMs, now, challengeId });
|
|
381
|
+
try {
|
|
382
|
+
await store.put(ref.object, ct);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
if (e instanceof BlobError) throw e;
|
|
385
|
+
throw new BlobUploadError(`blob upload failed for ${ref.object}: ${e.message}`, e);
|
|
386
|
+
}
|
|
387
|
+
if (broker) await broker.finalize({ challengeId, object: ref.object, hash: ref.hash, size: ref.size });
|
|
388
|
+
return { ref, metadata, ctx, size: buf.length, hash: ref.hash, object: ref.object };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* RECEIVE side. Gate order: ref validation (sig/expiry/recipient) ->
|
|
393
|
+
* sender key gate -> fetch -> content-hash check -> broker redemption
|
|
394
|
+
* (single-use, only after every prior gate passed) -> decrypt -> size check.
|
|
395
|
+
*
|
|
396
|
+
* `senderKeyCheck(uuaid, pubkeyHex) => status` is the registry check for a
|
|
397
|
+
* sender whose UUAID does NOT derive from the key that signed the ref. A
|
|
398
|
+
* derived sender is proven by the verified ref signature alone (the same
|
|
399
|
+
* rule its envelope was accepted on), so the check is not consulted for it.
|
|
400
|
+
*/
|
|
401
|
+
export async function fetchAndDecryptBlob({ ref, store, envelopeId, now = Date.now(), broker = null, senderKeyCheck = null }) {
|
|
402
|
+
validateBlobRef(ref, { recipient: null, now });
|
|
403
|
+
if (typeof ref.envelopeId === "string" && envelopeId && ref.envelopeId !== envelopeId) {
|
|
404
|
+
throw new BlobRefError(`blob-ref bound to envelope ${ref.envelopeId}, received in ${envelopeId}`);
|
|
405
|
+
}
|
|
406
|
+
// Sender key gate — BEFORE any redemption burns. Only a non-derived sender
|
|
407
|
+
// needs the registry to vouch for its key.
|
|
408
|
+
const selfCertified = senderSelfCertified(ref);
|
|
409
|
+
if (senderKeyCheck && !selfCertified) {
|
|
410
|
+
const status = await senderKeyCheck(ref.sender, ref.sig?.publicKey);
|
|
411
|
+
if (status !== "active") {
|
|
412
|
+
throw new BlobSenderKeyError(`sender key for ${ref.sender} is ${status ?? "unknown"}, not active — refusing redemption`, ref.sender, status);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
let ct;
|
|
416
|
+
try {
|
|
417
|
+
ct = await store.get(ref.object);
|
|
418
|
+
} catch (e) {
|
|
419
|
+
if (e instanceof BlobError) throw e;
|
|
420
|
+
throw new BlobFetchError(`blob fetch failed for ${ref.object}: ${e.message}`, e);
|
|
421
|
+
}
|
|
422
|
+
if (ct == null) throw new BlobNotFoundError(ref.object);
|
|
423
|
+
if (contentHash(ct) !== ref.hash) {
|
|
424
|
+
throw new BlobIntegrityError(`blob content hash mismatch for ${ref.object}`);
|
|
425
|
+
}
|
|
426
|
+
if (broker && ref.challengeId) await broker.redeem({ challengeId: ref.challengeId });
|
|
427
|
+
const data = decryptBlob({ ...blobRefSecret(ref), ct: Buffer.from(ct).toString("hex") }, {
|
|
428
|
+
envelopeId: ref.envelopeId, sender: ref.sender, recipient: ref.recipient,
|
|
429
|
+
object: ref.object, v: ref.v, expiry: ref.expiry,
|
|
430
|
+
});
|
|
431
|
+
if (data.length !== ref.plaintextSize) {
|
|
432
|
+
throw new BlobIntegrityError(`blob size mismatch: decrypted ${data.length}B, ref says ${ref.plaintextSize}B`);
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
data, object: ref.object, hash: ref.hash, size: ref.size,
|
|
436
|
+
sender: ref.sender, senderProof: selfCertified ? "self-certified" : senderKeyCheck ? "sender-key-check" : "unchecked",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Production send path through the recipient-authenticated UUAID HTTP broker. */
|
|
441
|
+
export async function prepareBrokerBlobSend(keychain, { data, envelopeId, recipient, ttlMs = DEFAULT_BLOB_TTL_MS, now = Date.now(), client }) {
|
|
442
|
+
if (!client) throw new BlobError("no-broker-client", "prepareBrokerBlobSend: broker client required");
|
|
443
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
444
|
+
if (buf.length > MAX_BLOB_PLAINTEXT_BYTES) {
|
|
445
|
+
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 });
|
|
446
|
+
}
|
|
447
|
+
const challenge = await client.challenge("reserve");
|
|
448
|
+
const prepared = makeBlobRef(keychain, {
|
|
449
|
+
envelopeId,
|
|
450
|
+
recipient,
|
|
451
|
+
plaintext: buf,
|
|
452
|
+
ttlMs,
|
|
453
|
+
now,
|
|
454
|
+
objectId: challenge.object_id,
|
|
455
|
+
broker: client.baseUrl,
|
|
456
|
+
});
|
|
457
|
+
const reserved = await client.reserve(challenge, {
|
|
458
|
+
recipient,
|
|
459
|
+
ciphertextSha256: prepared.ref.hash,
|
|
460
|
+
ciphertextBytes: prepared.ref.size,
|
|
461
|
+
expiresAt: prepared.ref.expiry,
|
|
462
|
+
});
|
|
463
|
+
if (reserved.object_id !== prepared.ref.object || reserved.expires_at !== prepared.ref.expiry) {
|
|
464
|
+
throw new BlobIntegrityError("broker reservation does not match the prepared blob-ref");
|
|
465
|
+
}
|
|
466
|
+
await client.upload(reserved.upload, prepared.ct);
|
|
467
|
+
const finalized = await client.finalize(prepared.ref.object);
|
|
468
|
+
client.crossCheckRedemption(prepared.ref, finalized.blob_ref);
|
|
469
|
+
return { ...prepared, size: buf.length, hash: prepared.ref.hash, object: prepared.ref.object };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Production receive path; the sender key gate precedes broker redemption.
|
|
474
|
+
* A sender whose UUAID is exactly the uuaid:foundation:agent UUAID of the
|
|
475
|
+
* ref's verified signing key passes without a badge; any other sender needs
|
|
476
|
+
* a fresh registry badge naming that key (client.assertRegistryActiveSender).
|
|
477
|
+
*
|
|
478
|
+
* The result names the sender and how it was proven (`senderProof`):
|
|
479
|
+
* "self-certified" means the key holder is proven but the identity is
|
|
480
|
+
* UNVERIFIED (no registry name or badge applies; show the UUAID only);
|
|
481
|
+
* "registry-badge" means a fresh registry badge names the signing key.
|
|
482
|
+
*/
|
|
483
|
+
export async function fetchBrokerBlob({ ref, envelopeId, recipient, now = Date.now(), client }) {
|
|
484
|
+
if (!client) throw new BlobError("no-broker-client", "fetchBrokerBlob: broker client required");
|
|
485
|
+
validateBlobRef(ref, { recipient, now });
|
|
486
|
+
if (ref.envelopeId !== envelopeId) throw new BlobRefError("blob-ref envelope binding mismatch");
|
|
487
|
+
if (ref.broker !== client.baseUrl) throw new BlobRefError("blob-ref broker binding mismatch");
|
|
488
|
+
const selfCertified = senderSelfCertified(ref);
|
|
489
|
+
if (!selfCertified) await client.assertRegistryActiveSender(ref.sender, ref.sig?.publicKey);
|
|
490
|
+
const redeemed = await client.redeem(ref.object);
|
|
491
|
+
client.crossCheckRedemption(ref, redeemed.blob);
|
|
492
|
+
const stored = await client.download(redeemed.download);
|
|
493
|
+
if (stored.length !== ref.size) throw new BlobIntegrityError(`blob ciphertext size mismatch: ${stored.length}B != ${ref.size}B`);
|
|
494
|
+
if (contentHash(stored) !== ref.hash) throw new BlobIntegrityError("blob ciphertext hash mismatch");
|
|
495
|
+
const data = decryptBlob({ ...blobRefSecret(ref), ct: stored.toString("hex") }, {
|
|
496
|
+
envelopeId: ref.envelopeId,
|
|
497
|
+
sender: ref.sender,
|
|
498
|
+
recipient: ref.recipient,
|
|
499
|
+
object: ref.object,
|
|
500
|
+
v: ref.v,
|
|
501
|
+
expiry: ref.expiry,
|
|
502
|
+
});
|
|
503
|
+
if (data.length !== ref.plaintextSize) {
|
|
504
|
+
throw new BlobIntegrityError(`blob plaintext size mismatch: ${data.length}B != ${ref.plaintextSize}B`);
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
data, object: ref.object, hash: ref.hash, size: ref.plaintextSize,
|
|
508
|
+
sender: ref.sender, senderProof: selfCertified ? "self-certified" : "registry-badge",
|
|
509
|
+
};
|
|
510
|
+
}
|
|
@@ -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
|
+
}
|