@feralfile/cli 2.8.0 → 2.10.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/package.json +1 -1
- package/dist/src/commands/config.js +12 -5
- package/dist/src/commands/enrich.js +135 -32
- package/dist/src/commands/publish.js +58 -1
- package/dist/src/commands/setup.js +42 -15
- package/dist/src/commands/sign.js +82 -7
- package/dist/src/commands/status.js +28 -2
- package/dist/src/commands/unpublish.js +52 -9
- package/dist/src/utilities/feed-mutation.js +119 -23
- package/dist/src/utilities/playlist-publisher.js +15 -26
- package/dist/src/utilities/playlist-signer.js +367 -22
- package/dist/src/utilities/playlist-unpublisher.js +11 -30
- package/dist/src/utilities/same-file.js +52 -0
- package/dist/src/utilities/signing-key-source.js +230 -0
- package/docs/CONFIGURATION.md +59 -3
- package/docs/EXAMPLES.md +209 -9
- package/docs/PROJECT_SPEC.md +45 -6
- package/docs/README.md +48 -7
- package/package.json +1 -1
|
@@ -101,24 +101,58 @@ async function verifyPlaylist(playlist, publicKeyHex) {
|
|
|
101
101
|
* @param {string} [roleOverride] - DP-1 signing role override
|
|
102
102
|
* @param {Object} [options] - Signing options
|
|
103
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
|
|
104
106
|
* @returns {Promise<Object>} Result with signed playlist
|
|
105
107
|
* @returns {boolean} returns.success - Whether signing succeeded
|
|
106
108
|
* @returns {Object} [returns.playlist] - Signed playlist object
|
|
107
|
-
* @returns {
|
|
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
|
|
108
111
|
* @returns {string} [returns.error] - Error message if failed
|
|
109
112
|
*/
|
|
110
113
|
async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride, options) {
|
|
111
|
-
|
|
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');
|
|
112
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;
|
|
113
130
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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');
|
|
119
145
|
const playlist = JSON.parse(playlistContent);
|
|
120
146
|
const config = getPlaylistConfig();
|
|
121
|
-
|
|
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;
|
|
122
156
|
const role = resolvePlaylistSigningRole(roleOverride || config.role);
|
|
123
157
|
const validation = await validatePlaylistForSigning(playlist);
|
|
124
158
|
if (!validation.valid) {
|
|
@@ -129,31 +163,342 @@ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, role
|
|
|
129
163
|
throw new Error('Private key is required for signing');
|
|
130
164
|
}
|
|
131
165
|
const replaceSignatures = Boolean(options && options.replaceSignatures);
|
|
132
|
-
const
|
|
133
|
-
? (Array.isArray(playlist.signatures) ? playlist.signatures.filter(Boolean).length : 0) +
|
|
134
|
-
(typeof playlist.signature === 'string' && playlist.signature.trim() ? 1 : 0)
|
|
135
|
-
: 0;
|
|
166
|
+
const force = Boolean(options && options.force);
|
|
136
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
|
+
: [];
|
|
137
174
|
const verification = await verifySignedPlaylistEnvelope(signedPlaylist, dp1);
|
|
138
175
|
if (!verification.valid) {
|
|
139
176
|
throw new Error(`Signed playlist verification failed: ${verification.error}`);
|
|
140
177
|
}
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|
|
150
314
|
}
|
|
151
315
|
catch (error) {
|
|
152
316
|
return {
|
|
153
317
|
success: false,
|
|
154
|
-
|
|
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,
|
|
155
324
|
};
|
|
156
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;
|
|
157
502
|
}
|
|
158
503
|
module.exports = {
|
|
159
504
|
signPlaylist,
|
|
@@ -20,8 +20,6 @@ exports.unpublishPlaylist = unpublishPlaylist;
|
|
|
20
20
|
const axios_1 = __importDefault(require("axios"));
|
|
21
21
|
const feed_mutation_1 = require("./feed-mutation");
|
|
22
22
|
Object.defineProperty(exports, "storedOwnerKeys", { enumerable: true, get: function () { return feed_mutation_1.storedOwnerKeys; } });
|
|
23
|
-
const signing_identity_1 = require("./signing-identity");
|
|
24
|
-
const config_1 = require("../config");
|
|
25
23
|
/**
|
|
26
24
|
* Look up a playlist on a feed without deleting it.
|
|
27
25
|
*
|
|
@@ -38,7 +36,7 @@ async function fetchPlaylistForUnpublish(idOrUrl, feedServerUrl) {
|
|
|
38
36
|
* 1. Resolve the id or URL the user supplied to a feed path segment.
|
|
39
37
|
* 2. `GET` the stored playlist — the intent must carry the stored id and slug, and only the stored
|
|
40
38
|
* document says who owns it.
|
|
41
|
-
* 3. Refuse locally when the
|
|
39
|
+
* 3. Refuse locally when the signing key is not a stored owner.
|
|
42
40
|
* 4. Build and sign the delete-intent in the `curator` role.
|
|
43
41
|
* 5. `DELETE` with the intent as the body and report the outcome.
|
|
44
42
|
*
|
|
@@ -55,16 +53,16 @@ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
|
|
|
55
53
|
if (!identifier) {
|
|
56
54
|
return { success: false, error: 'No playlist id or URL was given' };
|
|
57
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 ? '--key' : '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.
|
|
58
62
|
let privateKey;
|
|
59
|
-
try {
|
|
60
|
-
privateKey = resolveSigningKey(options.privateKey);
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
return { success: false, error: error.message };
|
|
64
|
-
}
|
|
65
63
|
let signerDidKey;
|
|
66
64
|
try {
|
|
67
|
-
signerDidKey = (0,
|
|
65
|
+
({ privateKey, didKey: signerDidKey } = (0, feed_mutation_1.mutationSignerIdentity)(options.privateKey, 'delete'));
|
|
68
66
|
}
|
|
69
67
|
catch (error) {
|
|
70
68
|
return { success: false, error: error.message };
|
|
@@ -76,7 +74,7 @@ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
|
|
|
76
74
|
catch (error) {
|
|
77
75
|
return {
|
|
78
76
|
success: false,
|
|
79
|
-
...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete'),
|
|
77
|
+
...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete', keySource),
|
|
80
78
|
feedServer: feedServerUrl,
|
|
81
79
|
};
|
|
82
80
|
}
|
|
@@ -92,7 +90,7 @@ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
|
|
|
92
90
|
feedServer: feedServerUrl,
|
|
93
91
|
};
|
|
94
92
|
}
|
|
95
|
-
const refusal = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'delete');
|
|
93
|
+
const refusal = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'delete', keySource);
|
|
96
94
|
if (refusal) {
|
|
97
95
|
return { success: false, ...refusal, feedServer: feedServerUrl };
|
|
98
96
|
}
|
|
@@ -135,25 +133,8 @@ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
|
|
|
135
133
|
catch (error) {
|
|
136
134
|
return {
|
|
137
135
|
success: false,
|
|
138
|
-
...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete'),
|
|
136
|
+
...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete', keySource),
|
|
139
137
|
feedServer: feedServerUrl,
|
|
140
138
|
};
|
|
141
139
|
}
|
|
142
140
|
}
|
|
143
|
-
/**
|
|
144
|
-
* Resolve the signing key the same way every other signing path does.
|
|
145
|
-
*
|
|
146
|
-
* `getPlaylistConfig` already layers config.json over `PLAYLIST_PRIVATE_KEY` and screens out the sample
|
|
147
|
-
* placeholder, so going through it keeps `unpublish` signing as the identity `ff-cli status` reports.
|
|
148
|
-
*/
|
|
149
|
-
function resolveSigningKey(override) {
|
|
150
|
-
if (override && override.trim().length > 0) {
|
|
151
|
-
return override;
|
|
152
|
-
}
|
|
153
|
-
const configured = (0, config_1.getPlaylistConfig)().privateKey;
|
|
154
|
-
if (!configured) {
|
|
155
|
-
throw new Error('No playlist signing key is configured. A delete is authorized by a signature, so one is ' +
|
|
156
|
-
'required: run "ff-cli setup" or set playlist.privateKey in config.json.');
|
|
157
|
-
}
|
|
158
|
-
return configured;
|
|
159
|
-
}
|
|
@@ -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
|
+
}
|