@feralfile/cli 2.7.0 → 2.9.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/dist/index.js +4 -0
- package/dist/package.json +1 -1
- package/dist/src/commands/enrich.js +12 -18
- package/dist/src/commands/fetch.js +89 -0
- package/dist/src/commands/find.js +13 -40
- package/dist/src/commands/helpers/feed-server.js +105 -0
- package/dist/src/commands/publish.js +59 -29
- package/dist/src/commands/sign.js +90 -1
- package/dist/src/commands/unpublish.js +145 -0
- package/dist/src/utilities/feed-mutation.js +454 -0
- package/dist/src/utilities/playlist-publisher.js +371 -148
- package/dist/src/utilities/playlist-signer.js +391 -20
- package/dist/src/utilities/playlist-unpublisher.js +140 -0
- package/dist/src/utilities/same-file.js +52 -0
- package/docs/CONFIGURATION.md +22 -2
- package/docs/EXAMPLES.md +370 -0
- package/docs/PROJECT_SPEC.md +63 -1
- package/docs/README.md +63 -2
- package/package.json +1 -1
|
@@ -98,23 +98,61 @@ async function verifyPlaylist(playlist, publicKeyHex) {
|
|
|
98
98
|
* @param {string} playlistPath - Path to playlist JSON file
|
|
99
99
|
* @param {string} [privateKeyBase64] - Ed25519 private key in hex or base64 format (optional, uses config if not provided)
|
|
100
100
|
* @param {string} [outputPath] - Output path (optional, overwrites input if not provided)
|
|
101
|
+
* @param {string} [roleOverride] - DP-1 signing role override
|
|
102
|
+
* @param {Object} [options] - Signing options
|
|
103
|
+
* @param {boolean} [options.replaceSignatures=false] - Drop every existing signature and sign fresh
|
|
104
|
+
* @param {boolean} [options.force=false] - Overwrite a destination holding a different document
|
|
105
|
+
* @param {Object} [options.fs] - Filesystem module override, for tests
|
|
101
106
|
* @returns {Promise<Object>} Result with signed playlist
|
|
102
107
|
* @returns {boolean} returns.success - Whether signing succeeded
|
|
103
108
|
* @returns {Object} [returns.playlist] - Signed playlist object
|
|
109
|
+
* @returns {Array<Object>} [returns.dropped] - The stale entries that were discarded, classified
|
|
110
|
+
* @returns {boolean} [returns.inPlace] - Whether the input file itself was overwritten
|
|
104
111
|
* @returns {string} [returns.error] - Error message if failed
|
|
105
112
|
*/
|
|
106
|
-
async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride) {
|
|
107
|
-
|
|
113
|
+
async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride, options) {
|
|
114
|
+
// One fs handle for every filesystem call in this function, so a test can record the ORDER of the
|
|
115
|
+
// backup and the source write. That order is the whole guarantee: a backup synced after the source
|
|
116
|
+
// was truncated protects nothing, and no assertion about either write alone can catch it.
|
|
117
|
+
const fs = (options && options.fs) || require('fs');
|
|
108
118
|
const path = require('path');
|
|
119
|
+
// Hold the source open for the whole call, and read its bytes through that descriptor.
|
|
120
|
+
//
|
|
121
|
+
// Everything downstream — the parsed document, the signature classification, the decision about what
|
|
122
|
+
// may be discarded — describes the file that was READ. Looking the path up again later to decide
|
|
123
|
+
// whether the output is that file compares against whatever is at the name now, so an atomic
|
|
124
|
+
// replacement in between lets a classification of one document authorize truncating another. The
|
|
125
|
+
// descriptor pins the identity the rest of the call is reasoning about.
|
|
126
|
+
let sourceFd;
|
|
127
|
+
const output = outputPath || playlistPath;
|
|
128
|
+
let createdOutput = false;
|
|
129
|
+
let wrote = false;
|
|
109
130
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
131
|
+
sourceFd = fs.openSync(playlistPath, fs.constants.O_RDONLY);
|
|
132
|
+
}
|
|
133
|
+
catch (openError) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
error: openError && openError.code === 'ENOENT'
|
|
137
|
+
? `Playlist file not found: ${playlistPath}`
|
|
138
|
+
: openError.message,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const sourceBytes = readAllFrom(fs, sourceFd);
|
|
143
|
+
const sourceDigest = digestOf(sourceBytes);
|
|
144
|
+
const playlistContent = sourceBytes.toString('utf-8');
|
|
115
145
|
const playlist = JSON.parse(playlistContent);
|
|
116
146
|
const config = getPlaylistConfig();
|
|
117
|
-
|
|
147
|
+
// Presence, not truthiness. `--key ""` is what an unset shell variable expands to; treating it as
|
|
148
|
+
// absent signs with the configured key while the user believes they supplied another one. The same
|
|
149
|
+
// hazard the feed mutations were just fixed for, and the signature it produces is just as wrong.
|
|
150
|
+
if (privateKeyBase64 !== undefined && String(privateKeyBase64).trim().length === 0) {
|
|
151
|
+
throw new Error('The --key value is empty. This usually means a shell variable did not expand ' +
|
|
152
|
+
'(for example --key "$SIGNING_KEY" with SIGNING_KEY unset). Refusing rather than falling ' +
|
|
153
|
+
'back to the configured key, which would sign under an identity you did not choose.');
|
|
154
|
+
}
|
|
155
|
+
const privateKey = privateKeyBase64 !== undefined ? privateKeyBase64 : config.privateKey;
|
|
118
156
|
const role = resolvePlaylistSigningRole(roleOverride || config.role);
|
|
119
157
|
const validation = await validatePlaylistForSigning(playlist);
|
|
120
158
|
if (!validation.valid) {
|
|
@@ -124,31 +162,352 @@ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, role
|
|
|
124
162
|
if (!privateKey) {
|
|
125
163
|
throw new Error('Private key is required for signing');
|
|
126
164
|
}
|
|
127
|
-
const
|
|
165
|
+
const replaceSignatures = Boolean(options && options.replaceSignatures);
|
|
166
|
+
const force = Boolean(options && options.force);
|
|
167
|
+
const signedPlaylist = await buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role, replaceSignatures);
|
|
168
|
+
// Classify against the signature just produced: its `kid` and `role` are what this run actually
|
|
169
|
+
// asserted, so no separate derivation is needed to tell which entries it supersedes.
|
|
170
|
+
const fresh = signedPlaylist.signatures[signedPlaylist.signatures.length - 1];
|
|
171
|
+
const dropped = replaceSignatures
|
|
172
|
+
? await describeDroppedSignatures(playlist, fresh?.kid, fresh?.role, dp1)
|
|
173
|
+
: [];
|
|
128
174
|
const verification = await verifySignedPlaylistEnvelope(signedPlaylist, dp1);
|
|
129
175
|
if (!verification.valid) {
|
|
130
176
|
throw new Error(`Signed playlist verification failed: ${verification.error}`);
|
|
131
177
|
}
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
178
|
+
// Bind the output to a descriptor BEFORE deciding whether it is the input, and write through that
|
|
179
|
+
// same descriptor. Checking a path and then writing to it is two lookups, and in a shared
|
|
180
|
+
// directory they can disagree: an --output that does not exist when it is checked can be a symlink
|
|
181
|
+
// to the input by the time it is written, so the refusal below never fires and the write follows
|
|
182
|
+
// the link into the input while the report calls it untouched. A descriptor cannot be re-pointed.
|
|
183
|
+
//
|
|
184
|
+
// Overwrite only a document this command has read.
|
|
185
|
+
//
|
|
186
|
+
// Every earlier version answered "is the output the input?" with file identity — inode, name,
|
|
187
|
+
// realpath — and every one of them lost to the same shape of race: a path can be re-pointed
|
|
188
|
+
// between any two syscalls, so a stat taken before the write says nothing about what the write
|
|
189
|
+
// will land on. Identity is the wrong question. What matters is not whether two names refer to one
|
|
190
|
+
// file but whether the bytes about to be destroyed are the bytes that were read and classified.
|
|
191
|
+
//
|
|
192
|
+
// So the output descriptor is read back and digested, and the comparison is against the digest of
|
|
193
|
+
// the source. Equal means this IS the inspected document, however the name got here, and the
|
|
194
|
+
// discard rules apply to it. Unequal means the destination holds something never looked at, which
|
|
195
|
+
// is refused rather than guessed about. Neither answer depends on a name staying still.
|
|
196
|
+
//
|
|
197
|
+
// O_CREAT|O_EXCL first: a success proves the file did not exist a moment ago, so it holds nothing
|
|
198
|
+
// to preserve and nothing to compare. On EEXIST the retry keeps O_CREAT so a dangling symlink gets
|
|
199
|
+
// its target created, and adds O_RDWR because the destination now has to be read back.
|
|
200
|
+
//
|
|
201
|
+
// No O_TRUNC anywhere: nothing is destroyed before the decision.
|
|
202
|
+
// Whether the operator already asked for a different destination, which changes what a refusal
|
|
203
|
+
// can usefully tell them.
|
|
204
|
+
const wroteElsewhere = outputPath !== undefined && path.resolve(outputPath) !== path.resolve(playlistPath);
|
|
205
|
+
let fd;
|
|
206
|
+
try {
|
|
207
|
+
fd = fs.openSync(output, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL);
|
|
208
|
+
createdOutput = true;
|
|
209
|
+
}
|
|
210
|
+
catch (openError) {
|
|
211
|
+
if (!openError || openError.code !== 'EEXIST') {
|
|
212
|
+
throw openError;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
fd = fs.openSync(output, fs.constants.O_RDWR | fs.constants.O_CREAT);
|
|
216
|
+
}
|
|
217
|
+
catch (retryError) {
|
|
218
|
+
if (retryError && retryError.code === 'EACCES') {
|
|
219
|
+
// The destination has to be read back before it can be destroyed, so a write-only file that
|
|
220
|
+
// already exists is no longer a usable target. Worth saying, because the errno alone reads
|
|
221
|
+
// like a bug rather than a rule.
|
|
222
|
+
throw new Error(`Cannot read ${output} to check what it holds (${retryError.message}). A destination that ` +
|
|
223
|
+
'already exists is read before it is overwritten, so it needs read permission. Write to ' +
|
|
224
|
+
'a new name instead.');
|
|
225
|
+
}
|
|
226
|
+
throw retryError;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
// `inPlace` now means "this descriptor holds the document that was read", which is the only
|
|
231
|
+
// sense the discard rules ever needed.
|
|
232
|
+
let inPlace = false;
|
|
233
|
+
let overwroteAnother = false;
|
|
234
|
+
if (!createdOutput) {
|
|
235
|
+
// This narrows the race; it does not close it. Between reading the destination here and
|
|
236
|
+
// truncating it below, another process can write to the same descriptor's file, and nothing
|
|
237
|
+
// available in userspace prevents that — file locking is not portable, and no command in this
|
|
238
|
+
// CLI attempts it. Every tool that edits a file in place carries the same window; `enrich`
|
|
239
|
+
// says so at its own equivalent check. A fresh --output name is the write that cannot collide,
|
|
240
|
+
// because nothing is there to lose.
|
|
241
|
+
const outputBytes = readAllFrom(fs, fd);
|
|
242
|
+
const outputDigest = digestOf(outputBytes);
|
|
243
|
+
inPlace = outputDigest === sourceDigest;
|
|
244
|
+
// An empty destination holds nothing to destroy, so there is nothing for the rule to protect.
|
|
245
|
+
// This is how a dangling symlink is written: O_EXCL will not follow the link, so the retry
|
|
246
|
+
// creates the target and cannot prove it did — but the file it opened is provably empty, which
|
|
247
|
+
// answers the same question the proof would have.
|
|
248
|
+
const empty = outputBytes.length === 0;
|
|
249
|
+
if (inPlace) {
|
|
250
|
+
// Refuse rather than overwrite a signature only its holder could reproduce. The invariant is
|
|
251
|
+
// that a still-valid endorsement from another key is never destroyed in place; scoped to what
|
|
252
|
+
// cannot be recovered, since your own entry is replaced by this run and an unverified one was
|
|
253
|
+
// not restorable from the input either.
|
|
254
|
+
const unrecoverable = dropped.filter((entry) => entry.kind !== 'replaced' && entry.verified && !entry.sameKey);
|
|
255
|
+
if (unrecoverable.length > 0) {
|
|
256
|
+
const count = unrecoverable.length;
|
|
257
|
+
const signatures = `${count} still-valid signature${count === 1 ? '' : 's'} from other keys`;
|
|
258
|
+
// Telling someone who already passed --output to "write it elsewhere" is not advice. They
|
|
259
|
+
// did; the file there holds the same document, which without identity is indistinguishable
|
|
260
|
+
// from an alias of the input — and identity is gone by design, because it could never
|
|
261
|
+
// answer the question anyway. So say what is true and what to do about it.
|
|
262
|
+
//
|
|
263
|
+
// --force deliberately does not reach here. It exists to replace a DIFFERENT document,
|
|
264
|
+
// where the only thing at risk is something the operator chose to discard. It is not a way
|
|
265
|
+
// to destroy a signature only its holder could reproduce.
|
|
266
|
+
throw new Error(wroteElsewhere
|
|
267
|
+
? `${output} holds the same document as the input, so writing there would discard ` +
|
|
268
|
+
`${signatures} exactly as writing in place would. --force does not override this: ` +
|
|
269
|
+
'it replaces a different document, never a signature only its holder could make ' +
|
|
270
|
+
'again.\n' +
|
|
271
|
+
` If ${output} is a separate copy you no longer need, delete it and re-run — a ` +
|
|
272
|
+
'name that does not exist is written with no check at all — or choose another ' +
|
|
273
|
+
'--output name.'
|
|
274
|
+
: `This would discard ${signatures}. Write the result elsewhere so the original ` +
|
|
275
|
+
'stays:\n' +
|
|
276
|
+
` ff-cli sign ${playlistPath} -r ${role} --replace-signatures --output <new file>`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else if (!empty && !force) {
|
|
280
|
+
// Something is there and it is not what was signed. Overwriting it would destroy a document
|
|
281
|
+
// this command never read, which no flag combination should do silently.
|
|
282
|
+
throw new Error(`${output} already exists and is not the playlist being signed; choose a new name, or ` +
|
|
283
|
+
'pass --force to overwrite it.');
|
|
284
|
+
}
|
|
285
|
+
else if (!empty) {
|
|
286
|
+
overwroteAnother = true;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Truncate only now, past every refusal, through the descriptor whose contents were just read.
|
|
290
|
+
fs.ftruncateSync(fd, 0);
|
|
291
|
+
writeAll(fs, fd, Buffer.from(JSON.stringify(signedPlaylist, null, 2), 'utf-8'));
|
|
292
|
+
// Durable before the success line prints: a report of a file that is not on disk is a lie the
|
|
293
|
+
// operator has no way to detect.
|
|
294
|
+
fs.fsyncSync(fd);
|
|
295
|
+
wrote = true;
|
|
296
|
+
console.log(`✓ Playlist signed and saved to: ${path.resolve(output)}`);
|
|
297
|
+
return {
|
|
298
|
+
success: true,
|
|
299
|
+
playlist: signedPlaylist,
|
|
300
|
+
dropped,
|
|
301
|
+
inPlace,
|
|
302
|
+
overwroteAnother,
|
|
303
|
+
outputPath: path.resolve(output),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
// Close, and nothing else. An earlier version removed the empty file a refusal had created, by
|
|
308
|
+
// name — and a name is exactly what this whole function stopped trusting. Between the close and
|
|
309
|
+
// the unlink the name can be another file, and deleting somebody else's document to tidy up
|
|
310
|
+
// after ourselves is a far worse outcome than the litter it was avoiding. The failure path says
|
|
311
|
+
// what may be there instead, and leaves it.
|
|
312
|
+
fs.closeSync(fd);
|
|
313
|
+
}
|
|
140
314
|
}
|
|
141
315
|
catch (error) {
|
|
142
316
|
return {
|
|
143
317
|
success: false,
|
|
144
|
-
|
|
318
|
+
// Said only when this call created the file, which O_EXCL told us for certain. Anything else
|
|
319
|
+
// would be a guess about a name we no longer hold open, and the point of saying it at all is so
|
|
320
|
+
// an operator is not surprised by a zero-byte playlist we deliberately did not remove.
|
|
321
|
+
error: createdOutput && !wrote
|
|
322
|
+
? `${error.message}\n An incomplete output may remain at ${output}.`
|
|
323
|
+
: error.message,
|
|
145
324
|
};
|
|
146
325
|
}
|
|
326
|
+
finally {
|
|
327
|
+
try {
|
|
328
|
+
fs.closeSync(sourceFd);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// Nothing to do; the result has already been decided.
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* readAllFrom reads a descriptor from byte zero, without depending on or moving its file offset.
|
|
337
|
+
*
|
|
338
|
+
* Positioned reads are used throughout so the same descriptor can be read twice — once for the
|
|
339
|
+
* document and once to check it has not changed — with no seek between them.
|
|
340
|
+
*
|
|
341
|
+
* @param {Object} fs - Node fs module
|
|
342
|
+
* @param {number} fd - Descriptor open for reading
|
|
343
|
+
* @returns {Buffer} Everything the descriptor holds
|
|
344
|
+
*/
|
|
345
|
+
function readAllFrom(fs, fd) {
|
|
346
|
+
const chunks = [];
|
|
347
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
348
|
+
let position = 0;
|
|
349
|
+
for (;;) {
|
|
350
|
+
const bytes = fs.readSync(fd, buffer, 0, buffer.length, position);
|
|
351
|
+
if (!(bytes > 0)) {
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
chunks.push(Buffer.from(buffer.subarray(0, bytes)));
|
|
355
|
+
position += bytes;
|
|
356
|
+
}
|
|
357
|
+
return Buffer.concat(chunks);
|
|
358
|
+
}
|
|
359
|
+
/** Content digest, the only comparison that answers "are these the same bytes". */
|
|
360
|
+
function digestOf(buffer) {
|
|
361
|
+
return require('crypto').createHash('sha256').update(buffer).digest('hex');
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* writeAll writes every byte of `buffer` to `fd`, or throws.
|
|
365
|
+
*
|
|
366
|
+
* `write(2)` may write fewer bytes than asked — a short write is not an error, it is the contract — and
|
|
367
|
+
* the return value was being ignored. Since this runs immediately after truncating the destination, a
|
|
368
|
+
* short write left a partial document on disk under a "Playlist signed" report: the one failure the
|
|
369
|
+
* operator cannot see, because the command said it succeeded.
|
|
370
|
+
*
|
|
371
|
+
* @param {Object} fs - Node fs module
|
|
372
|
+
* @param {number} fd - Descriptor open for writing
|
|
373
|
+
* @param {Buffer} buffer - Bytes to write
|
|
374
|
+
* @throws {Error} If the descriptor stops accepting bytes before the buffer is exhausted
|
|
375
|
+
*/
|
|
376
|
+
function writeAll(fs, fd, buffer) {
|
|
377
|
+
let offset = 0;
|
|
378
|
+
while (offset < buffer.length) {
|
|
379
|
+
const written = fs.writeSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
380
|
+
if (!(written > 0)) {
|
|
381
|
+
throw new Error(`Wrote only ${offset} of ${buffer.length} bytes; the file on disk is incomplete. ` +
|
|
382
|
+
'Re-run the command, and check the destination has space and is writable.');
|
|
383
|
+
}
|
|
384
|
+
offset += written;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Describe the signatures `--replace-signatures` discards, so the output can name them.
|
|
389
|
+
*
|
|
390
|
+
* A count alone is not actionable on a co-curated playlist: the owner has to know whose signatures are
|
|
391
|
+
* gone before they publish the replacement, not after someone notices their name missing.
|
|
392
|
+
*
|
|
393
|
+
* **An entry counts as replaced only when the fresh signature matches it in BOTH `kid` and `role`.**
|
|
394
|
+
* Matching on the key alone was wrong in a way that quietly produced unpublishable documents: re-signing
|
|
395
|
+
* as `agent` with a key that had signed as `curator` dropped the curator entry, called it "replaced",
|
|
396
|
+
* and left it out of the backup — and the result then fails the publisher's declared-curator preflight,
|
|
397
|
+
* with nothing left on disk to recover the lost signature from. Ownership lives in the role as much as
|
|
398
|
+
* in the key, so a role change is a loss like any other.
|
|
399
|
+
*
|
|
400
|
+
* **There is no "void" verdict here, and that is deliberate.** Calling an entry void asserts that it
|
|
401
|
+
* verified against the previous content and no longer does — a claim about a document this command has
|
|
402
|
+
* never seen. The operator edited in place; the pre-edit bytes exist nowhere by the time `sign` runs.
|
|
403
|
+
* A failed `VerifyMultiSignature` proves only that the entry does not verify against the document as it
|
|
404
|
+
* stands, which is equally consistent with an edit, with an entry that was tampered with, and with one
|
|
405
|
+
* that was never valid. So the verdict is exactly what can be checked: it verifies against this
|
|
406
|
+
* document, or it does not.
|
|
407
|
+
*
|
|
408
|
+
* A flat legacy `signature` is a third case again — no `kid`, no `role`, and nothing the
|
|
409
|
+
* multi-signature verifier can check — so it is reported as unverifiable rather than as a failure.
|
|
410
|
+
*
|
|
411
|
+
* It does not treat a `feed` role as "the feed's, so it comes back" either. Any key can emit a
|
|
412
|
+
* signature carrying `role: "feed"`, and this CLI holds no feed identity to check a `kid` against.
|
|
413
|
+
* Non-replaced entries are reported as removed, with their own role carried through — a document may
|
|
414
|
+
* legitimately hold `agent`, `institution`, or `licensor` entries. That a feed re-appends its own
|
|
415
|
+
* signature after verifying a replacement is stated separately, as the general fact it is.
|
|
416
|
+
*
|
|
417
|
+
* `kid` is reported as its last 8 characters: enough to match a curators[] row at a glance, with the
|
|
418
|
+
* full value still in the file.
|
|
419
|
+
*
|
|
420
|
+
* @param {Object} playlist - The playlist as read, before signing
|
|
421
|
+
* @param {string} [signingKid] - `did:key` of the key that just signed
|
|
422
|
+
* @param {string} [signingRole] - DP-1 role the fresh signature asserts
|
|
423
|
+
* @param {Object} dp1 - Loaded dp1-js module
|
|
424
|
+
* @returns {Promise<Array<{kind: string, sameKey: boolean, claimsSigningKey: boolean, role: string|null, kid: string|null, verified: boolean, checkable: boolean, label: string}>>}
|
|
425
|
+
*/
|
|
426
|
+
async function describeDroppedSignatures(playlist, signingKid, signingRole, dp1) {
|
|
427
|
+
const dropped = [];
|
|
428
|
+
const raw = Buffer.from(JSON.stringify(playlist));
|
|
429
|
+
for (const entry of Array.isArray(playlist.signatures) ? playlist.signatures : []) {
|
|
430
|
+
if (!entry) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const kid = typeof entry.kid === 'string' && entry.kid ? entry.kid : null;
|
|
434
|
+
const role = typeof entry.role === 'string' && entry.role ? entry.role : null;
|
|
435
|
+
const short = kid ? kid.slice(-8) : 'unknown';
|
|
436
|
+
const descriptor = role ? `${role}, ...${short}` : `no role, ...${short}`;
|
|
437
|
+
let verified = false;
|
|
438
|
+
try {
|
|
439
|
+
dp1.VerifyMultiSignature(raw, entry);
|
|
440
|
+
verified = true;
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
verified = false;
|
|
444
|
+
}
|
|
445
|
+
// A `kid` and a `role` are claims the entry makes about itself, and nothing in a document stops an
|
|
446
|
+
// attacker — or a corrupted file — from copying the signer's own. Only a signature that verifies
|
|
447
|
+
// has established whose it is, so an unverified entry is never credited as this key's, and never
|
|
448
|
+
// reported as replaced. Otherwise a forged entry carrying the signing key's kid and role would be
|
|
449
|
+
// labelled "your own earlier signature, replaced" and vanish from the unverified summary, which is
|
|
450
|
+
// precisely where a tampered signature most needs to appear.
|
|
451
|
+
const claimsSigningKey = Boolean(kid && signingKid && kid === signingKid);
|
|
452
|
+
const sameKey = verified && claimsSigningKey;
|
|
453
|
+
const replaced = sameKey && role === signingRole;
|
|
454
|
+
const outcome = verified
|
|
455
|
+
? 'removed; still valid over this content'
|
|
456
|
+
: 'removed; could not be verified against this document';
|
|
457
|
+
let label;
|
|
458
|
+
if (replaced) {
|
|
459
|
+
label = `your own earlier signature (${descriptor}) — replaced by this signing`;
|
|
460
|
+
}
|
|
461
|
+
else if (sameKey) {
|
|
462
|
+
// Same key, different role. Naming it as another key's would be wrong, and naming it as replaced
|
|
463
|
+
// would be worse: this run asserts a different role, so the entry is gone and not reinstated.
|
|
464
|
+
label = `your signature in another role (${descriptor}) — ${outcome}`;
|
|
465
|
+
}
|
|
466
|
+
else if (claimsSigningKey) {
|
|
467
|
+
// Carries this key's kid but does not verify, so whose it is was never established. Said as the
|
|
468
|
+
// claim it is: asserting it IS yours would credit a possible forgery, and asserting it is
|
|
469
|
+
// someone else's would misdescribe the ordinary case where you edited the document after signing.
|
|
470
|
+
label = `a signature claiming your key (${descriptor}) — ${outcome}`;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
label = `another key's signature (${descriptor}) — ${outcome}`;
|
|
474
|
+
}
|
|
475
|
+
dropped.push({
|
|
476
|
+
kind: replaced ? 'replaced' : 'other',
|
|
477
|
+
sameKey,
|
|
478
|
+
claimsSigningKey,
|
|
479
|
+
role,
|
|
480
|
+
kid,
|
|
481
|
+
verified,
|
|
482
|
+
checkable: true,
|
|
483
|
+
label,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
if (typeof playlist.signature === 'string' && playlist.signature.trim()) {
|
|
487
|
+
// A legacy flat signature carries neither kid nor role, and the multi-signature verifier has
|
|
488
|
+
// nothing to check it with. `checkable: false` keeps it out of both summaries: it is not a
|
|
489
|
+
// signature that failed, it is one nothing here can judge.
|
|
490
|
+
dropped.push({
|
|
491
|
+
kind: 'other',
|
|
492
|
+
sameKey: false,
|
|
493
|
+
claimsSigningKey: false,
|
|
494
|
+
role: null,
|
|
495
|
+
kid: null,
|
|
496
|
+
verified: false,
|
|
497
|
+
checkable: false,
|
|
498
|
+
label: 'a legacy flat signature (no kid, no role) — removed; not checkable here',
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return dropped;
|
|
147
502
|
}
|
|
148
503
|
module.exports = {
|
|
149
504
|
signPlaylist,
|
|
150
505
|
verifyPlaylist,
|
|
151
506
|
signPlaylistFile,
|
|
507
|
+
// Exported for the feed-mutation intents: a delete/replace intent is signed with the same key
|
|
508
|
+
// material as a playlist, so it must accept the same encodings and raise the same guidance when the
|
|
509
|
+
// key is malformed. Duplicating the normalizer there would let the two paths drift.
|
|
510
|
+
normalizeSigningKeyToBase64Pkcs8,
|
|
152
511
|
};
|
|
153
512
|
function resolvePlaylistSigningRole(role) {
|
|
154
513
|
const candidate = typeof role === 'string' ? role.trim() : '';
|
|
@@ -176,13 +535,25 @@ async function validatePlaylistForSigning(playlist) {
|
|
|
176
535
|
* as dp1-js/dp1-go §7.1); prior `signatures[]` entries are kept on the returned
|
|
177
536
|
* object so repeated `sign` runs accumulate endorsements instead of replacing them.
|
|
178
537
|
*
|
|
538
|
+
* `replaceSignatures` discards them instead, and exists because appending is only
|
|
539
|
+
* correct while the signed content is unchanged. Editing a signed document moves
|
|
540
|
+
* bytes the earlier entries cover, so they become unverifiable — and this function's
|
|
541
|
+
* caller verifies the whole envelope before writing, which means an edited document
|
|
542
|
+
* cannot be re-signed at all without dropping them. That is the ordinary path for a
|
|
543
|
+
* feed replace: fetch the published playlist, change a field, sign fresh, PUT.
|
|
544
|
+
*
|
|
545
|
+
* The feed's own `feed` entry is dropped by the same rule rather than as a special
|
|
546
|
+
* case: it covers the pre-edit content too, and the feed appends a new one after it
|
|
547
|
+
* verifies the replacement, so carrying the old one forward can only fail.
|
|
548
|
+
*
|
|
179
549
|
* @param {Object} playlist - Parsed playlist (may already include `signatures[]`)
|
|
180
550
|
* @param {string} privateKey - Private key material forwarded to dp1-js
|
|
181
551
|
* @param {Object} dp1 - Loaded dp1-js module
|
|
182
552
|
* @param {string} role - DP-1 signing role
|
|
553
|
+
* @param {boolean} [replaceSignatures=false] - Discard existing signatures instead of appending
|
|
183
554
|
* @returns {Promise<Object>} Playlist with legacy `signature` cleared and merged `signatures[]`
|
|
184
555
|
*/
|
|
185
|
-
async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
|
|
556
|
+
async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role, replaceSignatures) {
|
|
186
557
|
const playlistToSign = { ...playlist };
|
|
187
558
|
delete playlistToSign.signature;
|
|
188
559
|
delete playlistToSign.signatures;
|
|
@@ -190,7 +561,7 @@ async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
|
|
|
190
561
|
// seed, PEM) works and a malformed key surfaces a clear error instead of
|
|
191
562
|
// dp1-js's cryptic OpenSSL ASN.1 failure ("header too long" / "wrong tag").
|
|
192
563
|
const normalizedKey = normalizeSigningKeyToBase64Pkcs8(privateKey);
|
|
193
|
-
const existingSignatures = Array.isArray(playlist.signatures)
|
|
564
|
+
const existingSignatures = !replaceSignatures && Array.isArray(playlist.signatures)
|
|
194
565
|
? playlist.signatures.filter((entry) => Boolean(entry))
|
|
195
566
|
: [];
|
|
196
567
|
if (typeof dp1.SignMultiEd25519 === 'function') {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Deletes a playlist from a DP-1 feed.
|
|
4
|
+
*
|
|
5
|
+
* `curl -X DELETE` alone answers `401`: the feed has no API key, and a delete is authorized by a signed
|
|
6
|
+
* **delete-intent** in the request body (dp1-feed-v2 `docs/api_design.md`, "DELETE — owner-bound, signed
|
|
7
|
+
* delete-intent"). The intent must name the stored `id` and `slug` exactly, carry a `created` inside the
|
|
8
|
+
* server's freshness window, and be signed by a key the stored playlist names as an owner.
|
|
9
|
+
*
|
|
10
|
+
* The delete is permanent in a stronger sense than most: it tombstones the id, and a later create naming
|
|
11
|
+
* that id is refused. There is no undo, which is why the command layer confirms before calling this.
|
|
12
|
+
*/
|
|
13
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
14
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.storedOwnerKeys = void 0;
|
|
18
|
+
exports.fetchPlaylistForUnpublish = fetchPlaylistForUnpublish;
|
|
19
|
+
exports.unpublishPlaylist = unpublishPlaylist;
|
|
20
|
+
const axios_1 = __importDefault(require("axios"));
|
|
21
|
+
const feed_mutation_1 = require("./feed-mutation");
|
|
22
|
+
Object.defineProperty(exports, "storedOwnerKeys", { enumerable: true, get: function () { return feed_mutation_1.storedOwnerKeys; } });
|
|
23
|
+
/**
|
|
24
|
+
* Look up a playlist on a feed without deleting it.
|
|
25
|
+
*
|
|
26
|
+
* Exposed so the command layer can show the operator what they are about to destroy — and so the
|
|
27
|
+
* ownership refusal happens before any confirmation prompt, rather than after they have already said yes.
|
|
28
|
+
*/
|
|
29
|
+
async function fetchPlaylistForUnpublish(idOrUrl, feedServerUrl) {
|
|
30
|
+
return (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, (0, feed_mutation_1.resolvePlaylistIdentifier)(idOrUrl));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Delete a playlist from a feed server.
|
|
34
|
+
*
|
|
35
|
+
* Flow:
|
|
36
|
+
* 1. Resolve the id or URL the user supplied to a feed path segment.
|
|
37
|
+
* 2. `GET` the stored playlist — the intent must carry the stored id and slug, and only the stored
|
|
38
|
+
* document says who owns it.
|
|
39
|
+
* 3. Refuse locally when the signing key is not a stored owner.
|
|
40
|
+
* 4. Build and sign the delete-intent in the `curator` role.
|
|
41
|
+
* 5. `DELETE` with the intent as the body and report the outcome.
|
|
42
|
+
*
|
|
43
|
+
* @param idOrUrl - Playlist id, slug, or a feed URL ending in either
|
|
44
|
+
* @param feedServerUrl - Feed server base URL, including `/api/v1`
|
|
45
|
+
* @param options - Optional signing-key override
|
|
46
|
+
* @returns Result carrying the deleted playlist's identity, or a diagnosis and a remedy
|
|
47
|
+
* @example
|
|
48
|
+
* const result = await unpublishPlaylist('9f...c1', 'https://feed.example.com/api/v1');
|
|
49
|
+
* if (!result.success) console.error(result.error);
|
|
50
|
+
*/
|
|
51
|
+
async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
|
|
52
|
+
const identifier = (0, feed_mutation_1.resolvePlaylistIdentifier)(idOrUrl);
|
|
53
|
+
if (!identifier) {
|
|
54
|
+
return { success: false, error: 'No playlist id or URL was given' };
|
|
55
|
+
}
|
|
56
|
+
// Where the identity came from, resolved before the first request so every refusal — local or from
|
|
57
|
+
// the feed — names the key the operator actually used rather than a config file this run may not
|
|
58
|
+
// have read.
|
|
59
|
+
const keySource = options.keySource ?? (options.privateKey !== undefined ? 'supplied' : 'configured');
|
|
60
|
+
// Resolve and validate together: `--key ""` is a failed override, not an absent one, and must never
|
|
61
|
+
// fall through to the configured key on an operation that tombstones an id.
|
|
62
|
+
let privateKey;
|
|
63
|
+
let signerDidKey;
|
|
64
|
+
try {
|
|
65
|
+
({ privateKey, didKey: signerDidKey } = (0, feed_mutation_1.mutationSignerIdentity)(options.privateKey, 'delete'));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
return { success: false, error: error.message };
|
|
69
|
+
}
|
|
70
|
+
let stored;
|
|
71
|
+
try {
|
|
72
|
+
stored = await (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, identifier);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete', keySource),
|
|
78
|
+
feedServer: feedServerUrl,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const id = typeof stored.id === 'string' ? stored.id : '';
|
|
82
|
+
const slug = typeof stored.slug === 'string' ? stored.slug : '';
|
|
83
|
+
if (!id || !slug) {
|
|
84
|
+
// Both are stored verbatim from the signed document, so a feed serving one without the other is
|
|
85
|
+
// serving something this envelope cannot target. Failing here beats sending a body the feed will
|
|
86
|
+
// reject as a target mismatch, which reads like the operator's mistake.
|
|
87
|
+
return {
|
|
88
|
+
success: false,
|
|
89
|
+
error: 'The stored playlist is missing an id or slug, so a delete intent cannot target it.',
|
|
90
|
+
feedServer: feedServerUrl,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const refusal = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'delete', keySource);
|
|
94
|
+
if (refusal) {
|
|
95
|
+
return { success: false, ...refusal, feedServer: feedServerUrl };
|
|
96
|
+
}
|
|
97
|
+
const created = (0, feed_mutation_1.intentTimestamp)();
|
|
98
|
+
const intent = (0, feed_mutation_1.buildDeleteIntent)(id, slug, created);
|
|
99
|
+
let body;
|
|
100
|
+
try {
|
|
101
|
+
const signature = await (0, feed_mutation_1.signIntent)(intent, privateKey);
|
|
102
|
+
body = { ...intent, signatures: [signature] };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
success: false,
|
|
107
|
+
error: `Failed to sign the delete intent: ${error.message}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
// axios sends a body on DELETE only when `data` is given explicitly; the feed requires one.
|
|
112
|
+
const response = await axios_1.default.delete(`${feedServerUrl}/playlists/${encodeURIComponent(id)}`, {
|
|
113
|
+
data: body,
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
timeout: 30000,
|
|
116
|
+
});
|
|
117
|
+
if (response.status !== 204 && response.status !== 200) {
|
|
118
|
+
return {
|
|
119
|
+
success: false,
|
|
120
|
+
error: `Unexpected response status: ${response.status}`,
|
|
121
|
+
feedServer: feedServerUrl,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
success: true,
|
|
126
|
+
playlistId: id,
|
|
127
|
+
slug,
|
|
128
|
+
title: typeof stored.title === 'string' ? stored.title : undefined,
|
|
129
|
+
message: 'Deleted from feed server (the id is now tombstoned and cannot be reused)',
|
|
130
|
+
feedServer: feedServerUrl,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete', keySource),
|
|
137
|
+
feedServer: feedServerUrl,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* "Would renaming onto this destination replace the file the caller read?"
|
|
4
|
+
*
|
|
5
|
+
* One predicate, for one caller. `enrich` writes a temp file and renames it over the destination, and
|
|
6
|
+
* `rename()` does not follow the final component — so onto a hard link it repoints that one name and
|
|
7
|
+
* leaves the input's own name on the untouched original inode. A hard-link destination is a genuinely
|
|
8
|
+
* distinct output there, and treating it as in-place makes enrich's stale-input guard refuse a write
|
|
9
|
+
* that was never unsafe.
|
|
10
|
+
*
|
|
11
|
+
* There used to be an inode-identity predicate here too, for `sign`, which writes directly rather than
|
|
12
|
+
* by rename. It is gone, and deliberately: identity answers "is this the same file", when the question
|
|
13
|
+
* `sign` actually has is "are these the same bytes". A path can be re-pointed between any two calls, so
|
|
14
|
+
* no amount of stat comparison can promise that the thing about to be truncated is the thing that was
|
|
15
|
+
* read — while comparing contents through the descriptor being written can, and does. That check lives
|
|
16
|
+
* in playlist-signer.js, where the bytes are.
|
|
17
|
+
*/
|
|
18
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
19
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.isSameEntryForRename = isSameEntryForRename;
|
|
23
|
+
const fs_1 = __importDefault(require("fs"));
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
25
|
+
/**
|
|
26
|
+
* isSameEntryForRename reports whether renaming onto `a` would replace the name `b` is read from.
|
|
27
|
+
*
|
|
28
|
+
* True for the same path and for a symlink alias — both cases where the caller reads back changed
|
|
29
|
+
* content at the name they gave, since `writePlaylistAtomically` resolves a symlink to its target
|
|
30
|
+
* before replacing. Deliberately **false** for a hard link, because a rename repoints one directory
|
|
31
|
+
* entry and leaves the other name on the original inode.
|
|
32
|
+
*
|
|
33
|
+
* @param a - Destination of the rename
|
|
34
|
+
* @param b - Path whose contents matter
|
|
35
|
+
* @param fsLike - Filesystem operations, for tests
|
|
36
|
+
* @returns True when the rename would replace what `b` names
|
|
37
|
+
*/
|
|
38
|
+
async function isSameEntryForRename(a, b, fsLike = fs_1.default) {
|
|
39
|
+
if (path_1.default.resolve(a) === path_1.default.resolve(b)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const [left, right] = await Promise.all([
|
|
44
|
+
fsLike.promises.realpath(a),
|
|
45
|
+
fsLike.promises.realpath(b),
|
|
46
|
+
]);
|
|
47
|
+
return left === right;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|