@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- const feedServers = config.feedServers || [];
30
- if (feedServers.length > 0) {
31
- console.log(chalk_1.default.bold('\nFeed servers:\n'));
32
- feedServers.forEach((server) => {
33
- console.log(` ${chalk_1.default.dim(server.baseUrl)}`);
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 fs_1.promises.lstat(destination).catch(() => null);
48
+ const existing = await filesystem.lstat(destination).catch(() => null);
38
49
  if (existing?.isSymbolicLink()) {
39
- target = await fs_1.promises.realpath(destination);
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 fs_1.promises.stat(target).catch(() => null);
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 fs_1.promises.open(temporary, 'wx', mode);
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 fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
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 fs_1.promises.readFile(target).catch(() => null);
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 fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
183
+ await filesystem.rm(temporary, { force: true }).catch(() => undefined);
110
184
  return false;
111
185
  }
112
186
  }
113
- await fs_1.promises.rename(temporary, target);
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 fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
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
- constructor(target) {
130
- super(`cannot preserve ownership of ${target}`);
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
- * isSameFile reports whether two paths name the same file on disk.
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
- * Compares resolved paths rather than the strings the caller typed, so
139
- * `-o ./playlist.json` against `playlist.json`, and a symlink against its
140
- * target, are recognized as the in-place case they are.
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 isSameFile(a, b) {
143
- if (a === b) {
144
- return true;
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 fs_1.promises.open(directory, 'r');
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 isSameFile(file, destination)) ? originalDigest : undefined);
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
- console.error(chalk_1.default.red('\nThat playlist belongs to another user or group.'));
323
- console.error(chalk_1.default.dim(` Replacing it in place would hand it to you and could lock others out,\n` +
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
- if (nonInteractive) {
53
- // Key precedence: explicit --key, then --generate-key, then keep an
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.key) {
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, options.key, options.output, options.role, {
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
- // Report the discard explicitly. Dropping an entry is a real change to the document —
28
- // someone else's endorsement may have been in it — and staying silent would make a fresh
29
- // sign indistinguishable from an appending one.
30
- if (result.droppedSignatures > 0) {
31
- const plural = result.droppedSignatures === 1 ? '' : 's';
32
- console.log(chalk_1.default.dim(` Replaced ${result.droppedSignatures} existing signature${plural}`));
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}`));