@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
package/dist/package.json
CHANGED
|
@@ -26,11 +26,18 @@ exports.configCommand = new commander_1.Command('config')
|
|
|
26
26
|
const config = (0, config_1.getConfig)();
|
|
27
27
|
console.log(chalk_1.default.blue('\nCurrent configuration\n'));
|
|
28
28
|
console.log(chalk_1.default.bold('Default duration:'), chalk_1.default.white(config.defaultDuration + 's'));
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// The effective list, numbered. `-s <index>` on publish, fetch, replace
|
|
30
|
+
// and unpublish is an index into exactly this list — whichever of
|
|
31
|
+
// config.feedServers, the legacy feed.baseURLs, FEED_BASE_URLS, or the
|
|
32
|
+
// built-in default supplied it — so this is the one place an operator
|
|
33
|
+
// or an agent can read the number off before a write. Printing only
|
|
34
|
+
// config.feedServers, unnumbered, left the index undiscoverable for
|
|
35
|
+
// every other source.
|
|
36
|
+
const { baseURLs } = (0, config_1.getFeedConfig)();
|
|
37
|
+
if (baseURLs.length > 0) {
|
|
38
|
+
console.log(chalk_1.default.bold('\nFeed servers (index for -s):\n'));
|
|
39
|
+
baseURLs.forEach((url, index) => {
|
|
40
|
+
console.log(` ${chalk_1.default.bold(String(index))}: ${chalk_1.default.dim(url)}`);
|
|
34
41
|
});
|
|
35
42
|
}
|
|
36
43
|
const devices = config.ff1Devices?.devices || [];
|
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.enrichCommand = void 0;
|
|
7
|
+
exports.writePlaylistAtomically = writePlaylistAtomically;
|
|
7
8
|
const commander_1 = require("commander");
|
|
8
9
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
10
|
const fs_1 = require("fs");
|
|
@@ -11,6 +12,12 @@ const crypto_1 = require("crypto");
|
|
|
11
12
|
const path_1 = require("path");
|
|
12
13
|
const enrich_playlist_1 = require("../utilities/enrich-playlist");
|
|
13
14
|
const playlist_verifier_1 = require("../utilities/playlist-verifier");
|
|
15
|
+
// Rename semantics, not inode identity. This command writes a temp file and renames it over the
|
|
16
|
+
// destination, and rename() replaces a directory ENTRY: onto a hard link it repoints that one name and
|
|
17
|
+
// leaves the input's own name on the untouched original inode. So a hard-link -o is a genuinely
|
|
18
|
+
// distinct output here, and treating it as in-place makes the stale-input guard below refuse a write
|
|
19
|
+
// that was never unsafe. The signer's direct-write path needs the opposite predicate; see same-file.ts.
|
|
20
|
+
const same_file_1 = require("../utilities/same-file");
|
|
14
21
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
22
|
const { resolveTokenBatch } = require('../utilities/nft-indexer');
|
|
16
23
|
/**
|
|
@@ -28,32 +35,67 @@ const { resolveTokenBatch } = require('../utilities/nft-indexer');
|
|
|
28
35
|
* The temporary file is created alongside the destination rather than in the
|
|
29
36
|
* system temp directory: rename is only atomic within a filesystem, and those
|
|
30
37
|
* are not guaranteed to be the same one.
|
|
38
|
+
*
|
|
39
|
+
* Exported for tests: whether a destination counts as the input depends on this
|
|
40
|
+
* function's rename semantics, and pairing it with the wrong predicate is a
|
|
41
|
+
* mistake that has already been made once.
|
|
31
42
|
*/
|
|
32
|
-
async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
43
|
+
async function writePlaylistAtomically(destination, contents, expectedDigest, filesystem = fs_1.promises) {
|
|
33
44
|
// Follow a symlink to its target before replacing anything. rename() would
|
|
34
45
|
// replace the link itself, silently detaching a playlist that other paths
|
|
35
46
|
// reach through that name while reporting success.
|
|
36
47
|
let target = destination;
|
|
37
|
-
const existing = await
|
|
48
|
+
const existing = await filesystem.lstat(destination).catch(() => null);
|
|
38
49
|
if (existing?.isSymbolicLink()) {
|
|
39
|
-
target = await
|
|
50
|
+
target = await filesystem.realpath(destination);
|
|
40
51
|
}
|
|
41
52
|
const temporary = (0, path_1.join)((0, path_1.dirname)(target), `.${(0, path_1.basename)(target)}.${process.pid}.${(0, crypto_1.randomBytes)(4).toString('hex')}.tmp`);
|
|
53
|
+
// What a replacement preserves, and what it cannot.
|
|
54
|
+
//
|
|
55
|
+
// Preserved: the permission mode — applied before the file holds anything,
|
|
56
|
+
// then restored and verified against the inode after each of the two things
|
|
57
|
+
// that clear set-user-ID and set-group-ID, which are the chown and the write
|
|
58
|
+
// itself — and the owner and group, where a failed chown refuses the
|
|
59
|
+
// replacement outright. Those are what decide access for a file whose
|
|
60
|
+
// permissions are described by mode bits alone, which is the common case.
|
|
61
|
+
// Where any of it cannot be reproduced the write is refused rather than
|
|
62
|
+
// completed with different access.
|
|
63
|
+
//
|
|
64
|
+
// NOT preserved: access-control lists. A replacement is a new inode, and a
|
|
65
|
+
// new inode's ACL comes from the directory's default ACL, not from the file
|
|
66
|
+
// being replaced — so named entries granting or denying specific users and
|
|
67
|
+
// groups are gone, and whatever the directory hands out is there instead.
|
|
68
|
+
// Mode bits cannot carry them across, because mode bits cannot describe a
|
|
69
|
+
// named entry at all: on Linux the group bits are the POSIX mask rather than
|
|
70
|
+
// the group's own permissions, macOS extended ACLs are evaluated ahead of the
|
|
71
|
+
// mode and ignore the mask entirely, and Windows ACLs are not mode bits in
|
|
72
|
+
// any sense. Node exposes no portable way to read an ACL, let alone reapply
|
|
73
|
+
// one, so this is a limit of the replacement strategy and not an oversight
|
|
74
|
+
// that a few more syscalls would close. Reading the mode and finding it
|
|
75
|
+
// unchanged proves nothing about whether access widened or narrowed.
|
|
76
|
+
//
|
|
77
|
+
// What to do when it matters: enrich to a fresh name with `--output`, in a
|
|
78
|
+
// directory whose access is what you want the result to have. That is a
|
|
79
|
+
// create rather than a replace, so nothing is silently reassigned — the new
|
|
80
|
+
// file simply has the access its directory gives it, visibly.
|
|
81
|
+
//
|
|
42
82
|
// The destination's mode has to be known before the file exists, not after
|
|
43
83
|
// it holds the contents. Creating at the default 0o666-minus-umask and
|
|
44
84
|
// chmod'ing afterwards leaves the enriched playlist readable by other local
|
|
45
85
|
// users for the length of the write — short, but a disclosure window on a
|
|
46
86
|
// file the curator deliberately restricted.
|
|
47
|
-
const current = await
|
|
87
|
+
const current = await filesystem.stat(target).catch(() => null);
|
|
48
88
|
const mode = current ? current.mode & 0o7777 : undefined;
|
|
49
89
|
try {
|
|
50
90
|
// 'wx' fails rather than truncating if the name somehow exists, so a
|
|
51
91
|
// collision can never destroy another process's work in progress.
|
|
52
|
-
const handle = await
|
|
92
|
+
const handle = await filesystem.open(temporary, 'wx', mode);
|
|
53
93
|
try {
|
|
54
94
|
// open() applies the mode through umask, which can strip bits the
|
|
55
95
|
// destination had. chmod is not subject to umask, so this restores the
|
|
56
|
-
// exact mode — still before any bytes are written.
|
|
96
|
+
// exact mode — still before any bytes are written. It runs here, before
|
|
97
|
+
// the chown below, so the disclosure window stays closed even though
|
|
98
|
+
// chown will undo part of it.
|
|
57
99
|
if (mode !== undefined) {
|
|
58
100
|
await handle.chmod(mode);
|
|
59
101
|
}
|
|
@@ -77,11 +119,43 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
|
77
119
|
await handle.chown(current.uid, current.gid);
|
|
78
120
|
}
|
|
79
121
|
catch {
|
|
80
|
-
await
|
|
81
|
-
throw new OwnershipError(target);
|
|
122
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
123
|
+
throw new OwnershipError(target, 'ownership');
|
|
82
124
|
}
|
|
125
|
+
// chown clears set-user-ID and set-group-ID. POSIX requires it of any
|
|
126
|
+
// chown by a process without appropriate privileges, and Linux does it
|
|
127
|
+
// for an executable regardless — the whole point is that a setuid file
|
|
128
|
+
// must not survive changing hands, since it would then run as its new
|
|
129
|
+
// owner. Correct in general, and wrong for this one case: the file is
|
|
130
|
+
// not changing hands, it is being restored to the hands it was already
|
|
131
|
+
// in. The restore happens just below, in the same step that verifies
|
|
132
|
+
// it.
|
|
83
133
|
}
|
|
134
|
+
// Verify against the inode rather than trusting the calls above.
|
|
135
|
+
//
|
|
136
|
+
// Both chmod calls can report success and still leave a different mode:
|
|
137
|
+
// restoring set-group-ID needs the caller to be in the file's group, and
|
|
138
|
+
// a filesystem may decline the bit outright. Every one of those endings
|
|
139
|
+
// is the same fact — this replacement would not carry the access the
|
|
140
|
+
// original had — and the whole reason the ownership branch refuses is
|
|
141
|
+
// that completing such a write silently is worse than not writing.
|
|
142
|
+
//
|
|
143
|
+
// Checked here so a refusal costs nothing already on disk, and again
|
|
144
|
+
// after the write below, which can undo it.
|
|
145
|
+
await requirePreservedMode(handle, mode, temporary, target, filesystem);
|
|
84
146
|
await handle.writeFile(contents);
|
|
147
|
+
// Write clears set-user-ID and set-group-ID too, for the same reason
|
|
148
|
+
// chown does: POSIX permits it on any write to a regular file by a
|
|
149
|
+
// process without appropriate privileges, and Linux does it. So the
|
|
150
|
+
// check above was necessary and not sufficient — a mode that passed it
|
|
151
|
+
// could still be renamed into place as 0o0750 where the original was
|
|
152
|
+
// 0o6750. Restore and verify once more, with nothing left that can
|
|
153
|
+
// change it: after this point the file is only synced and renamed.
|
|
154
|
+
//
|
|
155
|
+
// The earlier chmod still cannot move here. It is what keeps the
|
|
156
|
+
// enriched contents from being briefly world-readable, and that window
|
|
157
|
+
// opens the moment bytes exist.
|
|
158
|
+
await requirePreservedMode(handle, mode, temporary, target, filesystem);
|
|
85
159
|
// rename() is atomic against concurrent readers but says nothing about
|
|
86
160
|
// power loss: the directory entry can reach disk before the data it
|
|
87
161
|
// points at. Without this a crash mid-write can leave the playlist
|
|
@@ -103,19 +177,19 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
|
103
177
|
// out entirely, which is the safe path when a file is being actively
|
|
104
178
|
// edited.
|
|
105
179
|
if (expectedDigest !== undefined) {
|
|
106
|
-
const current = await
|
|
180
|
+
const current = await filesystem.readFile(target).catch(() => null);
|
|
107
181
|
const digest = current && (0, crypto_1.createHash)('sha256').update(current).digest('hex');
|
|
108
182
|
if (digest !== expectedDigest) {
|
|
109
|
-
await
|
|
183
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
110
184
|
return false;
|
|
111
185
|
}
|
|
112
186
|
}
|
|
113
|
-
await
|
|
114
|
-
await syncDirectory((0, path_1.dirname)(target));
|
|
187
|
+
await filesystem.rename(temporary, target);
|
|
188
|
+
await syncDirectory((0, path_1.dirname)(target), filesystem);
|
|
115
189
|
return true;
|
|
116
190
|
}
|
|
117
191
|
catch (error) {
|
|
118
|
-
await
|
|
192
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
119
193
|
throw error;
|
|
120
194
|
}
|
|
121
195
|
}
|
|
@@ -126,28 +200,50 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
|
126
200
|
*/
|
|
127
201
|
class OwnershipError extends Error {
|
|
128
202
|
target;
|
|
129
|
-
|
|
130
|
-
|
|
203
|
+
kind;
|
|
204
|
+
constructor(target, kind = 'ownership') {
|
|
205
|
+
super(`cannot preserve ${kind} of ${target}`);
|
|
131
206
|
this.target = target;
|
|
207
|
+
this.kind = kind;
|
|
132
208
|
this.name = 'OwnershipError';
|
|
133
209
|
}
|
|
134
210
|
}
|
|
135
211
|
/**
|
|
136
|
-
*
|
|
212
|
+
* requirePreservedMode re-applies the destination's mode to the replacement and
|
|
213
|
+
* proves it took.
|
|
214
|
+
*
|
|
215
|
+
* Called twice, because two different operations clear set-user-ID and
|
|
216
|
+
* set-group-ID and both sit between the first chmod and the rename: chown does
|
|
217
|
+
* it, and so does an unprivileged write to a regular file. POSIX permits the
|
|
218
|
+
* clearing in both cases and Linux performs it, so a mode verified before the
|
|
219
|
+
* write can still reach the destination as something narrower.
|
|
137
220
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
221
|
+
* Verified against the inode rather than inferred from the calls, because a
|
|
222
|
+
* chmod can report success and still not take: restoring set-group-ID needs the
|
|
223
|
+
* caller to be in the file's group, and a filesystem may decline the bit
|
|
224
|
+
* outright. Every one of those endings is the same fact — this replacement
|
|
225
|
+
* would not carry the access the original had — and completing such a write
|
|
226
|
+
* silently is precisely what the ownership refusal exists to prevent.
|
|
227
|
+
*
|
|
228
|
+
* A destination that did not exist has no mode to preserve, so this is a no-op.
|
|
229
|
+
*
|
|
230
|
+
* @param handle - Open temporary file that will be renamed over the destination
|
|
231
|
+
* @param mode - Permission bits the destination carried, or undefined
|
|
232
|
+
* @param temporary - Path of the temporary file, removed on refusal
|
|
233
|
+
* @param target - Destination, named in the refusal
|
|
234
|
+
* @param filesystem - Filesystem in use
|
|
235
|
+
* @throws OwnershipError when the mode cannot be reproduced
|
|
141
236
|
*/
|
|
142
|
-
async function
|
|
143
|
-
if (
|
|
144
|
-
return
|
|
237
|
+
async function requirePreservedMode(handle, mode, temporary, target, filesystem) {
|
|
238
|
+
if (mode === undefined) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await handle.chmod(mode);
|
|
242
|
+
const written = await handle.stat();
|
|
243
|
+
if ((written.mode & 0o7777) !== mode) {
|
|
244
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
245
|
+
throw new OwnershipError(target, 'permissions');
|
|
145
246
|
}
|
|
146
|
-
const [left, right] = await Promise.all([
|
|
147
|
-
fs_1.promises.realpath(a).catch(() => null),
|
|
148
|
-
fs_1.promises.realpath(b).catch(() => null),
|
|
149
|
-
]);
|
|
150
|
-
return left !== null && left === right;
|
|
151
247
|
}
|
|
152
248
|
/**
|
|
153
249
|
* syncDirectory flushes a directory entry so a rename survives power loss.
|
|
@@ -158,9 +254,9 @@ async function isSameFile(a, b) {
|
|
|
158
254
|
* the rename, so the worst case here is the old name surviving a crash, not a
|
|
159
255
|
* corrupt file.
|
|
160
256
|
*/
|
|
161
|
-
async function syncDirectory(directory) {
|
|
257
|
+
async function syncDirectory(directory, filesystem) {
|
|
162
258
|
try {
|
|
163
|
-
const handle = await
|
|
259
|
+
const handle = await filesystem.open(directory, 'r');
|
|
164
260
|
try {
|
|
165
261
|
await handle.sync();
|
|
166
262
|
}
|
|
@@ -284,7 +380,7 @@ exports.enrichCommand = new commander_1.Command('enrich')
|
|
|
284
380
|
if (!after.valid) {
|
|
285
381
|
reportInvalid('Enrichment produced an invalid playlist:', after);
|
|
286
382
|
}
|
|
287
|
-
const replaced = await writePlaylistAtomically(destination, JSON.stringify(result.playlist, null, 2), (await
|
|
383
|
+
const replaced = await writePlaylistAtomically(destination, JSON.stringify(result.playlist, null, 2), (await (0, same_file_1.isSameEntryForRename)(destination, file)) ? originalDigest : undefined);
|
|
288
384
|
if (!replaced) {
|
|
289
385
|
console.error(chalk_1.default.red('\nThat playlist changed while the lookup ran.'));
|
|
290
386
|
console.error(chalk_1.default.dim(' Enrichment was computed from the earlier version, so writing it\n' +
|
|
@@ -319,8 +415,15 @@ exports.enrichCommand = new commander_1.Command('enrich')
|
|
|
319
415
|
}
|
|
320
416
|
catch (error) {
|
|
321
417
|
if (error instanceof OwnershipError) {
|
|
322
|
-
|
|
323
|
-
|
|
418
|
+
// Two endings, one remedy. Say which one it was: "belongs to another user" sends someone to
|
|
419
|
+
// check ownership, and that is the wrong place to look when the ownership carried across fine
|
|
420
|
+
// and a set-group-ID bit is what could not be reproduced.
|
|
421
|
+
console.error(chalk_1.default.red(error.kind === 'ownership'
|
|
422
|
+
? '\nThat playlist belongs to another user or group.'
|
|
423
|
+
: '\nThat playlist has permissions this replacement cannot reproduce.'));
|
|
424
|
+
console.error(chalk_1.default.dim((error.kind === 'ownership'
|
|
425
|
+
? ` Replacing it in place would hand it to you and could lock others out,\n`
|
|
426
|
+
: ` Replacing it in place would give it access the original did not have,\n`) +
|
|
324
427
|
` so nothing was written. Write elsewhere instead:\n` +
|
|
325
428
|
` ff-cli enrich ${error.target} -o enriched.json\n`));
|
|
326
429
|
process.exit(1);
|
|
@@ -7,16 +7,69 @@ exports.publishCommand = void 0;
|
|
|
7
7
|
const commander_1 = require("commander");
|
|
8
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
9
|
const feed_server_1 = require("./helpers/feed-server");
|
|
10
|
+
const signing_key_source_1 = require("../utilities/signing-key-source");
|
|
10
11
|
exports.publishCommand = new commander_1.Command('publish')
|
|
11
12
|
.description('Publish a playlist to a feed server')
|
|
12
13
|
.argument('<file>', 'Path to the playlist file')
|
|
13
14
|
.option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
|
|
14
15
|
.option('--replace', 'Replace the playlist already stored under this document id, instead of creating a new one')
|
|
16
|
+
.option('-k, --key <privateKey>', 'Ed25519 private key that signs the replace authorization (overrides config; --replace only)')
|
|
17
|
+
.option('--key-file <path>', 'Read the key that signs the replace authorization from this file, not the command line')
|
|
15
18
|
.action(async (file, options) => {
|
|
16
19
|
try {
|
|
20
|
+
// A plain publish signs nothing at request time — it uploads the signatures[] envelope the
|
|
21
|
+
// document already carries — so a key flag would do nothing here. Refusing beats accepting it
|
|
22
|
+
// silently: someone passing a key believes it is being used, and a no-op flag on a command
|
|
23
|
+
// that writes to a feed is the kind of quiet lie this CLI has been removing.
|
|
24
|
+
//
|
|
25
|
+
// Tested for PRESENCE, not truthiness. `--key ""` is what an unset shell variable expands to,
|
|
26
|
+
// and it is still a key the user meant to supply; skipping the refusal for it published the
|
|
27
|
+
// document while leaving them believing a key had been checked.
|
|
28
|
+
//
|
|
29
|
+
// The flag is resolved to a NAME here, not to key material: this branch is about to refuse, and
|
|
30
|
+
// opening someone's key file only to tell them the flag does not apply reads their private key
|
|
31
|
+
// for no reason at all.
|
|
32
|
+
const keyFlag = (0, signing_key_source_1.explicitSigningKeyFlag)(options);
|
|
33
|
+
if (keyFlag !== undefined && !options.replace) {
|
|
34
|
+
console.error(chalk_1.default.red(`\n${keyFlag} has no effect on a plain publish`));
|
|
35
|
+
console.log(chalk_1.default.yellow(' A publish is authorized by the signatures already inside the document; the command\n' +
|
|
36
|
+
' signs nothing. Sign the file first, with the key you meant:\n' +
|
|
37
|
+
` ff-cli sign <file> -r curator ${keyFlag} <${keyFlag === '--key-file' ? 'file holding the private key' : 'private key'}>\n` +
|
|
38
|
+
` ${keyFlag} applies to "publish --replace", which signs an authorization intent.\n`));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
17
41
|
console.log(chalk_1.default.blue(options.replace ? '\nReplace playlist\n' : '\nPublish playlist\n'));
|
|
18
42
|
const { getFeedConfig } = await import('../config.js');
|
|
19
43
|
const { publishPlaylist, replacePlaylist } = await import('../utilities/playlist-publisher.js');
|
|
44
|
+
// Validate the signing credential before the server is chosen, for a replace.
|
|
45
|
+
//
|
|
46
|
+
// Selecting between several configured feeds is a question put to the operator; asking it and
|
|
47
|
+
// only then rejecting their key spends their attention on a run that could never have completed.
|
|
48
|
+
// The credential is already in hand, so it is checked without asking anybody anything.
|
|
49
|
+
//
|
|
50
|
+
// Only for --replace: a plain publish signs nothing at request time and must keep working with
|
|
51
|
+
// no key configured at all, so requiring one here would break it.
|
|
52
|
+
//
|
|
53
|
+
// A `--key-file` is read as part of the same step: a missing, unreadable, or empty key file is a
|
|
54
|
+
// credential failure like any other, and it belongs before the server question for the same
|
|
55
|
+
// reason a malformed key does.
|
|
56
|
+
let replaceKey;
|
|
57
|
+
let keySource = 'configured';
|
|
58
|
+
if (options.replace) {
|
|
59
|
+
const { mutationSignerIdentity } = await import('../utilities/feed-mutation.js');
|
|
60
|
+
const { resolveExplicitSigningKey } = await import('../utilities/signing-key-source.js');
|
|
61
|
+
try {
|
|
62
|
+
const explicitKey = resolveExplicitSigningKey(options);
|
|
63
|
+
keySource = explicitKey?.flag ?? 'configured';
|
|
64
|
+
replaceKey = mutationSignerIdentity(explicitKey?.material, 'replace').privateKey;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
console.error(chalk_1.default.red('\nCannot sign the replacement'));
|
|
68
|
+
console.error(chalk_1.default.red(` ${error.message}`));
|
|
69
|
+
console.log();
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
20
73
|
const feedConfig = getFeedConfig();
|
|
21
74
|
const selection = await (0, feed_server_1.selectFeedServer)(feedConfig.baseURLs, { serverArg: options.server });
|
|
22
75
|
if (!selection.ok) {
|
|
@@ -31,7 +84,11 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
31
84
|
// loudly on a duplicate id, a replace overwrites a published document — and an operator who typed
|
|
32
85
|
// neither flag meant the safe one.
|
|
33
86
|
const result = options.replace
|
|
34
|
-
? await replacePlaylist(file, selection.url
|
|
87
|
+
? await replacePlaylist(file, selection.url, {
|
|
88
|
+
// The material resolved above, so the identity validated here is the one that signs.
|
|
89
|
+
privateKey: replaceKey,
|
|
90
|
+
keySource,
|
|
91
|
+
})
|
|
35
92
|
: await publishPlaylist(file, selection.url);
|
|
36
93
|
if (result.success) {
|
|
37
94
|
console.log(chalk_1.default.green(options.replace ? 'Replaced' : 'Published'));
|
|
@@ -16,6 +16,7 @@ const device_discovery_1 = require("./helpers/device-discovery");
|
|
|
16
16
|
const prompt_1 = require("./helpers/prompt");
|
|
17
17
|
const config_placeholders_1 = require("../utilities/config-placeholders");
|
|
18
18
|
const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
|
|
19
|
+
const signing_key_source_1 = require("../utilities/signing-key-source");
|
|
19
20
|
const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
|
|
20
21
|
/** Generate a fresh Ed25519 signing key as base64 PKCS#8 DER (the recommended form). */
|
|
21
22
|
function generateSigningKeyBase64() {
|
|
@@ -26,6 +27,7 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
26
27
|
.description('Guided setup for config, signing key, and device')
|
|
27
28
|
.option('-y, --non-interactive', 'Run without prompts (for agents/CI); uses flags and safe defaults. Auto-enabled when stdin is not a TTY.')
|
|
28
29
|
.option('--key <privateKey>', 'Signing key material (base64 PKCS#8 DER, 32-byte seed as hex/base64, or PEM)')
|
|
30
|
+
.option('--key-file <path>', 'Read the signing key from this file instead of the command line (preferred for provisioning)')
|
|
29
31
|
.option('--generate-key', 'Generate a new Ed25519 signing key')
|
|
30
32
|
.option('--role <role>', `Signing role (${playlist_signing_role_1.DP1_PLAYLIST_SIGNING_ROLES.join(', ')})`)
|
|
31
33
|
.option('--device-host <host>', 'FF1 device host, e.g. http://192.168.1.50:1111 (skips mDNS discovery)')
|
|
@@ -39,6 +41,39 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
39
41
|
// Lazily open the prompt so non-interactive runs never touch stdin.
|
|
40
42
|
const getAsk = () => (prompt ??= (0, prompt_1.createPrompt)()).ask;
|
|
41
43
|
try {
|
|
44
|
+
// Resolve and validate an explicit key BEFORE anything touches disk.
|
|
45
|
+
//
|
|
46
|
+
// ensureConfigFile() writes a sample config when none exists, so resolving afterwards meant a
|
|
47
|
+
// run that could never succeed — a missing key file, an unreadable one, both flags at once, a
|
|
48
|
+
// key that does not parse — still left a config.json behind. On a machine being provisioned
|
|
49
|
+
// that is the difference between "nothing happened, fix the flag" and a half-configured host
|
|
50
|
+
// whose state the next run has to reason about.
|
|
51
|
+
//
|
|
52
|
+
// Provisioning is exactly where `--key` hurts most: an unattended run puts the key in the
|
|
53
|
+
// process list of a machine nobody is watching, and in whatever shell or CI log recorded the
|
|
54
|
+
// command. `--key-file` reads it from a file the provisioner already had to place.
|
|
55
|
+
//
|
|
56
|
+
// It is honoured on the interactive path too, not only under --non-interactive. A key on the
|
|
57
|
+
// command line is an instruction; prompting past it — "Keep existing signing key?" — would ask
|
|
58
|
+
// a question the operator has already answered, and answering it for them by ignoring the flag
|
|
59
|
+
// is the quiet lie this CLI keeps removing.
|
|
60
|
+
const explicitKey = (0, signing_key_source_1.resolveExplicitSigningKey)(options);
|
|
61
|
+
if (explicitKey !== undefined) {
|
|
62
|
+
// Validate eagerly so a bad key fails here with a clear message rather
|
|
63
|
+
// than later inside dp1-js during signing.
|
|
64
|
+
//
|
|
65
|
+
// Presence, not truthiness: `--key ""` is what an unset shell variable expands to, and it
|
|
66
|
+
// used to fall through to "generate a new key" — provisioning a machine with an identity
|
|
67
|
+
// nobody chose, silently, which is the whole hazard this flag family exists around. It now
|
|
68
|
+
// fails the same validation any other unusable value does.
|
|
69
|
+
try {
|
|
70
|
+
(0, ed25519_key_derive_1.parsePlaylistPrivateKeyToKeyObject)(explicitKey.material);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
throw new Error(`Invalid ${explicitKey.flag}: ${error.message}. ` +
|
|
74
|
+
'Expected base64 PKCS#8 DER, a 32-byte raw seed as hex/base64, or PEM.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
42
77
|
const { path: configPath, created } = await (0, config_files_1.ensureConfigFile)();
|
|
43
78
|
if (created) {
|
|
44
79
|
console.log(chalk_1.default.green(`Created ${configPath}`));
|
|
@@ -49,22 +84,14 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
49
84
|
const currentRole = config.playlist?.role || '';
|
|
50
85
|
let signingKey = currentKey;
|
|
51
86
|
let signingRole = currentRole;
|
|
52
|
-
|
|
53
|
-
|
|
87
|
+
// Already resolved and validated above, before any file was created.
|
|
88
|
+
if (explicitKey !== undefined) {
|
|
89
|
+
signingKey = explicitKey.material;
|
|
90
|
+
}
|
|
91
|
+
else if (nonInteractive) {
|
|
92
|
+
// Key precedence with no explicit key: --generate-key, then keep an
|
|
54
93
|
// existing usable key, otherwise generate one so signing works.
|
|
55
|
-
if (options.
|
|
56
|
-
// Validate eagerly so a bad key fails here with a clear message rather
|
|
57
|
-
// than later inside dp1-js during signing.
|
|
58
|
-
try {
|
|
59
|
-
(0, ed25519_key_derive_1.parsePlaylistPrivateKeyToKeyObject)(options.key);
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
throw new Error(`Invalid --key: ${error.message}. ` +
|
|
63
|
-
'Expected base64 PKCS#8 DER, a 32-byte raw seed as hex/base64, or PEM.');
|
|
64
|
-
}
|
|
65
|
-
signingKey = options.key;
|
|
66
|
-
}
|
|
67
|
-
else if (options.generateKey || (0, config_files_1.isMissingConfigValue)(currentKey)) {
|
|
94
|
+
if (options.generateKey || (0, config_files_1.isMissingConfigValue)(currentKey)) {
|
|
68
95
|
signingKey = generateSigningKeyBase64();
|
|
69
96
|
console.log(chalk_1.default.dim('Generated a new Ed25519 signing key.'));
|
|
70
97
|
}
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.signCommand = void 0;
|
|
7
7
|
const commander_1 = require("commander");
|
|
8
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const signing_key_source_1 = require("../utilities/signing-key-source");
|
|
9
10
|
// playlist-signer is still CommonJS; require keeps the interop simple.
|
|
10
11
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
11
12
|
const { signPlaylistFile } = require('../utilities/playlist-signer');
|
|
@@ -13,23 +14,97 @@ exports.signCommand = new commander_1.Command('sign')
|
|
|
13
14
|
.description('Sign a DP-1 playlist file with a DP-1 signature envelope')
|
|
14
15
|
.argument('<file>', 'Path to the playlist file to sign')
|
|
15
16
|
.option('-k, --key <privateKey>', 'Ed25519 private key in base64 format (overrides config)')
|
|
17
|
+
.option('--key-file <path>', 'Read the Ed25519 private key from this file instead of the command line')
|
|
16
18
|
.option('-r, --role <role>', 'DP-1 signing role (overrides config)')
|
|
17
19
|
.option('-o, --output <file>', 'Output file path (defaults to overwriting input file)')
|
|
18
20
|
.option('--replace-signatures', 'Discard the existing signatures and sign fresh (use after editing a signed playlist)')
|
|
21
|
+
.option('--force', 'Overwrite an output file that holds a different document (refused without this)')
|
|
19
22
|
.action(async (file, options) => {
|
|
20
23
|
try {
|
|
24
|
+
// Resolve the explicit key before the playlist is opened, so an unreadable or empty key file
|
|
25
|
+
// costs nothing but the run. `--key` material is passed through exactly as typed, including
|
|
26
|
+
// when it is empty: signPlaylistFile refuses that with the shell-expansion message, which is
|
|
27
|
+
// the right diagnosis for a value from the command line and the wrong one for a file.
|
|
28
|
+
const explicitKey = (0, signing_key_source_1.resolveExplicitSigningKey)(options);
|
|
21
29
|
console.log(chalk_1.default.blue('\nSign playlist\n'));
|
|
22
|
-
const result = await signPlaylistFile(file,
|
|
30
|
+
const result = await signPlaylistFile(file, explicitKey?.material, options.output, options.role, {
|
|
23
31
|
replaceSignatures: !!options.replaceSignatures,
|
|
32
|
+
force: !!options.force,
|
|
24
33
|
});
|
|
25
34
|
if (result.success) {
|
|
26
35
|
console.log(chalk_1.default.green('\nPlaylist signed'));
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
// Name the casualty. --force overwrote a document this command never read, and the only
|
|
37
|
+
// record that it existed is now this line.
|
|
38
|
+
if (result.overwroteAnother) {
|
|
39
|
+
console.log(chalk_1.default.yellow(` Overwrote a different document at ${result.outputPath} (--force)`));
|
|
40
|
+
}
|
|
41
|
+
// Name every discarded entry, and say only what was actually checked.
|
|
42
|
+
//
|
|
43
|
+
// Your own earlier signature is free: this command replaces it. For the rest the only
|
|
44
|
+
// establishable fact is whether the entry verifies against the document as it stands —
|
|
45
|
+
// "void" would assert it verified against the PREVIOUS content, which was edited in place
|
|
46
|
+
// and exists nowhere by the time this runs. A failed verification is equally consistent
|
|
47
|
+
// with an edit, with a tampered entry, and with one that was never valid, so it is reported
|
|
48
|
+
// as unverified rather than diagnosed.
|
|
49
|
+
//
|
|
50
|
+
// Roles are carried through rather than assumed: a document may hold `agent`,
|
|
51
|
+
// `institution`, or `licensor` entries, and a `feed` role is not special-cased because any
|
|
52
|
+
// key can emit one and this CLI has no feed identity to check a kid against.
|
|
53
|
+
const dropped = result.dropped ?? [];
|
|
54
|
+
if (dropped.length > 0) {
|
|
55
|
+
const plural = dropped.length === 1 ? '' : 's';
|
|
56
|
+
console.log(chalk_1.default.dim(` Replaced ${dropped.length} existing signature${plural}:`));
|
|
57
|
+
for (const entry of dropped) {
|
|
58
|
+
console.log(chalk_1.default.dim(` - ${entry.label}`));
|
|
59
|
+
}
|
|
60
|
+
// Everything this run did not supersede. A same-key entry in another role belongs here:
|
|
61
|
+
// the fresh signature asserts a different role, so that entry is gone and not reinstated.
|
|
62
|
+
const others = dropped.filter((entry) => entry.kind !== 'replaced' && entry.checkable);
|
|
63
|
+
const stillValid = others.filter((entry) => entry.verified);
|
|
64
|
+
const unverified = others.filter((entry) => !entry.verified);
|
|
65
|
+
if (stillValid.length > 0) {
|
|
66
|
+
const noun = stillValid.length === 1 ? 'signature' : 'signatures';
|
|
67
|
+
const it = stillValid.length === 1 ? 'it' : 'them';
|
|
68
|
+
// Say where they still exist. Only two cases reach here: an --output run, where the
|
|
69
|
+
// input is untouched and holds them; or an in-place run whose still-valid entries are
|
|
70
|
+
// all this key's own in another role, which nobody else has to be asked for. An
|
|
71
|
+
// in-place run that would drop another key's still-valid signature never gets this far
|
|
72
|
+
// — it is refused, because that file is the only copy and its holder is the only one
|
|
73
|
+
// who could make another.
|
|
74
|
+
const where = result.inPlace
|
|
75
|
+
? ` ${it.charAt(0).toUpperCase()}${it.slice(1)} ${stillValid.length === 1 ? 'was' : 'were'} made by your own key in another role; sign again to add back any you still want.`
|
|
76
|
+
: ` Your input file is untouched, so ${it} ${stillValid.length === 1 ? 'remains' : 'remain'} valid there.`;
|
|
77
|
+
console.log(chalk_1.default.yellow(` ${stillValid.length} other ${noun} still verified over this content and ` +
|
|
78
|
+
`${stillValid.length === 1 ? 'was' : 'were'} removed anyway.\n` +
|
|
79
|
+
where));
|
|
80
|
+
}
|
|
81
|
+
if (unverified.length > 0) {
|
|
82
|
+
const noun = unverified.length === 1 ? 'signature' : 'signatures';
|
|
83
|
+
// Stated as what was observed, with both explanations, and no instruction that would
|
|
84
|
+
// only make sense under one of them.
|
|
85
|
+
console.log(chalk_1.default.yellow(` ${unverified.length} other ${noun} could not be verified against this document:`));
|
|
86
|
+
for (const entry of unverified) {
|
|
87
|
+
const who = entry.kid ? `...${entry.kid.slice(-8)}` : 'unknown key';
|
|
88
|
+
// "claims" is not decoration: an unverified entry carrying this key's kid has not
|
|
89
|
+
// established whose it is, and reading it as yours is how a forgery would hide.
|
|
90
|
+
const mine = entry.sameKey
|
|
91
|
+
? ' — your own key'
|
|
92
|
+
: entry.claimsSigningKey
|
|
93
|
+
? ' — claims your key, unverified'
|
|
94
|
+
: '';
|
|
95
|
+
console.log(chalk_1.default.yellow(` ${who}${entry.role ? ` (${entry.role})` : ''}${mine}`));
|
|
96
|
+
}
|
|
97
|
+
console.log(chalk_1.default.yellow(` That is consistent with the content having changed since they were made, and\n` +
|
|
98
|
+
` equally with their never having been valid — this command only has the document\n` +
|
|
99
|
+
` as it stands, so it cannot tell which. If you want those signatures on what you\n` +
|
|
100
|
+
` publish, their holders have to sign this document; signing appends, so they can\n` +
|
|
101
|
+
` add to this file without disturbing yours.`));
|
|
102
|
+
}
|
|
103
|
+
if (dropped.some((entry) => !entry.checkable)) {
|
|
104
|
+
console.log(chalk_1.default.yellow(` A legacy flat signature carries no kid or role, so nothing here can judge it.`));
|
|
105
|
+
}
|
|
106
|
+
// The general fact, never a claim about a specific entry above.
|
|
107
|
+
console.log(chalk_1.default.dim(` A feed appends its own signature again after it verifies a replacement.`));
|
|
33
108
|
}
|
|
34
109
|
if (Array.isArray(result.playlist?.signatures)) {
|
|
35
110
|
console.log(chalk_1.default.dim(` Signatures: ${result.playlist.signatures.length}`));
|