@feralfile/cli 2.9.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 +128 -19
- package/dist/src/commands/publish.js +22 -7
- package/dist/src/commands/setup.js +42 -15
- package/dist/src/commands/sign.js +8 -1
- package/dist/src/commands/status.js +28 -2
- package/dist/src/commands/unpublish.js +11 -10
- package/dist/src/utilities/feed-mutation.js +29 -16
- package/dist/src/utilities/playlist-publisher.js +1 -1
- package/dist/src/utilities/playlist-unpublisher.js +1 -1
- package/dist/src/utilities/signing-key-source.js +230 -0
- package/docs/CONFIGURATION.md +58 -4
- package/docs/EXAMPLES.md +36 -7
- package/docs/PROJECT_SPEC.md +14 -5
- package/docs/README.md +35 -9
- 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 || [];
|
|
@@ -40,31 +40,62 @@ const { resolveTokenBatch } = require('../utilities/nft-indexer');
|
|
|
40
40
|
* function's rename semantics, and pairing it with the wrong predicate is a
|
|
41
41
|
* mistake that has already been made once.
|
|
42
42
|
*/
|
|
43
|
-
async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
43
|
+
async function writePlaylistAtomically(destination, contents, expectedDigest, filesystem = fs_1.promises) {
|
|
44
44
|
// Follow a symlink to its target before replacing anything. rename() would
|
|
45
45
|
// replace the link itself, silently detaching a playlist that other paths
|
|
46
46
|
// reach through that name while reporting success.
|
|
47
47
|
let target = destination;
|
|
48
|
-
const existing = await
|
|
48
|
+
const existing = await filesystem.lstat(destination).catch(() => null);
|
|
49
49
|
if (existing?.isSymbolicLink()) {
|
|
50
|
-
target = await
|
|
50
|
+
target = await filesystem.realpath(destination);
|
|
51
51
|
}
|
|
52
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
|
+
//
|
|
53
82
|
// The destination's mode has to be known before the file exists, not after
|
|
54
83
|
// it holds the contents. Creating at the default 0o666-minus-umask and
|
|
55
84
|
// chmod'ing afterwards leaves the enriched playlist readable by other local
|
|
56
85
|
// users for the length of the write — short, but a disclosure window on a
|
|
57
86
|
// file the curator deliberately restricted.
|
|
58
|
-
const current = await
|
|
87
|
+
const current = await filesystem.stat(target).catch(() => null);
|
|
59
88
|
const mode = current ? current.mode & 0o7777 : undefined;
|
|
60
89
|
try {
|
|
61
90
|
// 'wx' fails rather than truncating if the name somehow exists, so a
|
|
62
91
|
// collision can never destroy another process's work in progress.
|
|
63
|
-
const handle = await
|
|
92
|
+
const handle = await filesystem.open(temporary, 'wx', mode);
|
|
64
93
|
try {
|
|
65
94
|
// open() applies the mode through umask, which can strip bits the
|
|
66
95
|
// destination had. chmod is not subject to umask, so this restores the
|
|
67
|
-
// 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.
|
|
68
99
|
if (mode !== undefined) {
|
|
69
100
|
await handle.chmod(mode);
|
|
70
101
|
}
|
|
@@ -88,11 +119,43 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
|
88
119
|
await handle.chown(current.uid, current.gid);
|
|
89
120
|
}
|
|
90
121
|
catch {
|
|
91
|
-
await
|
|
92
|
-
throw new OwnershipError(target);
|
|
122
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
123
|
+
throw new OwnershipError(target, 'ownership');
|
|
93
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.
|
|
94
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);
|
|
95
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);
|
|
96
159
|
// rename() is atomic against concurrent readers but says nothing about
|
|
97
160
|
// power loss: the directory entry can reach disk before the data it
|
|
98
161
|
// points at. Without this a crash mid-write can leave the playlist
|
|
@@ -114,19 +177,19 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
|
114
177
|
// out entirely, which is the safe path when a file is being actively
|
|
115
178
|
// edited.
|
|
116
179
|
if (expectedDigest !== undefined) {
|
|
117
|
-
const current = await
|
|
180
|
+
const current = await filesystem.readFile(target).catch(() => null);
|
|
118
181
|
const digest = current && (0, crypto_1.createHash)('sha256').update(current).digest('hex');
|
|
119
182
|
if (digest !== expectedDigest) {
|
|
120
|
-
await
|
|
183
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
121
184
|
return false;
|
|
122
185
|
}
|
|
123
186
|
}
|
|
124
|
-
await
|
|
125
|
-
await syncDirectory((0, path_1.dirname)(target));
|
|
187
|
+
await filesystem.rename(temporary, target);
|
|
188
|
+
await syncDirectory((0, path_1.dirname)(target), filesystem);
|
|
126
189
|
return true;
|
|
127
190
|
}
|
|
128
191
|
catch (error) {
|
|
129
|
-
await
|
|
192
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
130
193
|
throw error;
|
|
131
194
|
}
|
|
132
195
|
}
|
|
@@ -137,12 +200,51 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
|
137
200
|
*/
|
|
138
201
|
class OwnershipError extends Error {
|
|
139
202
|
target;
|
|
140
|
-
|
|
141
|
-
|
|
203
|
+
kind;
|
|
204
|
+
constructor(target, kind = 'ownership') {
|
|
205
|
+
super(`cannot preserve ${kind} of ${target}`);
|
|
142
206
|
this.target = target;
|
|
207
|
+
this.kind = kind;
|
|
143
208
|
this.name = 'OwnershipError';
|
|
144
209
|
}
|
|
145
210
|
}
|
|
211
|
+
/**
|
|
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.
|
|
220
|
+
*
|
|
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
|
|
236
|
+
*/
|
|
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');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
146
248
|
/**
|
|
147
249
|
* syncDirectory flushes a directory entry so a rename survives power loss.
|
|
148
250
|
*
|
|
@@ -152,9 +254,9 @@ class OwnershipError extends Error {
|
|
|
152
254
|
* the rename, so the worst case here is the old name surviving a crash, not a
|
|
153
255
|
* corrupt file.
|
|
154
256
|
*/
|
|
155
|
-
async function syncDirectory(directory) {
|
|
257
|
+
async function syncDirectory(directory, filesystem) {
|
|
156
258
|
try {
|
|
157
|
-
const handle = await
|
|
259
|
+
const handle = await filesystem.open(directory, 'r');
|
|
158
260
|
try {
|
|
159
261
|
await handle.sync();
|
|
160
262
|
}
|
|
@@ -313,8 +415,15 @@ exports.enrichCommand = new commander_1.Command('enrich')
|
|
|
313
415
|
}
|
|
314
416
|
catch (error) {
|
|
315
417
|
if (error instanceof OwnershipError) {
|
|
316
|
-
|
|
317
|
-
|
|
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`) +
|
|
318
427
|
` so nothing was written. Write elsewhere instead:\n` +
|
|
319
428
|
` ff-cli enrich ${error.target} -o enriched.json\n`));
|
|
320
429
|
process.exit(1);
|
|
@@ -7,28 +7,35 @@ 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')
|
|
15
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')
|
|
16
18
|
.action(async (file, options) => {
|
|
17
19
|
try {
|
|
18
20
|
// A plain publish signs nothing at request time — it uploads the signatures[] envelope the
|
|
19
|
-
// document already carries — so
|
|
21
|
+
// document already carries — so a key flag would do nothing here. Refusing beats accepting it
|
|
20
22
|
// silently: someone passing a key believes it is being used, and a no-op flag on a command
|
|
21
23
|
// that writes to a feed is the kind of quiet lie this CLI has been removing.
|
|
22
24
|
//
|
|
23
25
|
// Tested for PRESENCE, not truthiness. `--key ""` is what an unset shell variable expands to,
|
|
24
26
|
// and it is still a key the user meant to supply; skipping the refusal for it published the
|
|
25
27
|
// document while leaving them believing a key had been checked.
|
|
26
|
-
|
|
27
|
-
|
|
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`));
|
|
28
35
|
console.log(chalk_1.default.yellow(' A publish is authorized by the signatures already inside the document; the command\n' +
|
|
29
36
|
' signs nothing. Sign the file first, with the key you meant:\n' +
|
|
30
|
-
|
|
31
|
-
|
|
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`));
|
|
32
39
|
process.exit(1);
|
|
33
40
|
}
|
|
34
41
|
console.log(chalk_1.default.blue(options.replace ? '\nReplace playlist\n' : '\nPublish playlist\n'));
|
|
@@ -42,11 +49,19 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
42
49
|
//
|
|
43
50
|
// Only for --replace: a plain publish signs nothing at request time and must keep working with
|
|
44
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.
|
|
45
56
|
let replaceKey;
|
|
57
|
+
let keySource = 'configured';
|
|
46
58
|
if (options.replace) {
|
|
47
59
|
const { mutationSignerIdentity } = await import('../utilities/feed-mutation.js');
|
|
60
|
+
const { resolveExplicitSigningKey } = await import('../utilities/signing-key-source.js');
|
|
48
61
|
try {
|
|
49
|
-
|
|
62
|
+
const explicitKey = resolveExplicitSigningKey(options);
|
|
63
|
+
keySource = explicitKey?.flag ?? 'configured';
|
|
64
|
+
replaceKey = mutationSignerIdentity(explicitKey?.material, 'replace').privateKey;
|
|
50
65
|
}
|
|
51
66
|
catch (error) {
|
|
52
67
|
console.error(chalk_1.default.red('\nCannot sign the replacement'));
|
|
@@ -72,7 +87,7 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
72
87
|
? await replacePlaylist(file, selection.url, {
|
|
73
88
|
// The material resolved above, so the identity validated here is the one that signs.
|
|
74
89
|
privateKey: replaceKey,
|
|
75
|
-
keySource
|
|
90
|
+
keySource,
|
|
76
91
|
})
|
|
77
92
|
: await publishPlaylist(file, selection.url);
|
|
78
93
|
if (result.success) {
|
|
@@ -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,14 +14,20 @@ 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)')
|
|
19
21
|
.option('--force', 'Overwrite an output file that holds a different document (refused without this)')
|
|
20
22
|
.action(async (file, options) => {
|
|
21
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);
|
|
22
29
|
console.log(chalk_1.default.blue('\nSign playlist\n'));
|
|
23
|
-
const result = await signPlaylistFile(file,
|
|
30
|
+
const result = await signPlaylistFile(file, explicitKey?.material, options.output, options.role, {
|
|
24
31
|
replaceSignatures: !!options.replaceSignatures,
|
|
25
32
|
force: !!options.force,
|
|
26
33
|
});
|
|
@@ -12,6 +12,7 @@ const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
|
|
|
12
12
|
const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
|
|
13
13
|
const signing_identity_1 = require("../utilities/signing-identity");
|
|
14
14
|
const config_placeholders_1 = require("../utilities/config-placeholders");
|
|
15
|
+
const signing_key_source_1 = require("../utilities/signing-key-source");
|
|
15
16
|
exports.statusCommand = new commander_1.Command('status')
|
|
16
17
|
.description('Show configuration status')
|
|
17
18
|
// Identity discovery must cover every key `sign` accepts, not just the configured one. `sign --key`
|
|
@@ -19,9 +20,34 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
19
20
|
// user the wrong did:key to declare in curators[] — and a wrong declaration fails exactly like a
|
|
20
21
|
// missing one.
|
|
21
22
|
.option('-k, --key <privateKey>', 'Report the signing identity for this key instead of the configured one')
|
|
23
|
+
.option('--key-file <path>', 'Read the key to report on from this file instead of the command line')
|
|
22
24
|
.action(async (options) => {
|
|
23
25
|
try {
|
|
24
|
-
|
|
26
|
+
// An unusable key file is reported here rather than allowed to fall through. This command's whole
|
|
27
|
+
// job is to answer "which identity is this?", so silently answering about the configured key
|
|
28
|
+
// instead — which an empty file would do, since the check below is truthiness — would hand back a
|
|
29
|
+
// did:key that belongs to something else and looks exactly like the right answer.
|
|
30
|
+
let explicitKey;
|
|
31
|
+
try {
|
|
32
|
+
explicitKey = (0, signing_key_source_1.resolveExplicitSigningKey)(options);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.log(chalk_1.default.red(error.message));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const overrideKey = explicitKey?.material.trim();
|
|
39
|
+
// Presence, not truthiness — the same rule `sign` and the feed mutations enforce, and the reason
|
|
40
|
+
// it is enforced here too. `--key ""` is what `--key "$SIGNING_KEY"` becomes when the variable is
|
|
41
|
+
// unset, and falling through to the configured identity answers "whose key is this?" with a
|
|
42
|
+
// different key's did:key. Nothing about that answer looks wrong, so it would be copied into
|
|
43
|
+
// curators[], and a wrong declaration fails exactly like a missing one.
|
|
44
|
+
if (explicitKey !== undefined && !overrideKey) {
|
|
45
|
+
console.log(chalk_1.default.red(`The ${explicitKey.flag} value is empty. This usually means a shell variable did not ` +
|
|
46
|
+
`expand (for example ${explicitKey.flag} "$SIGNING_KEY" with SIGNING_KEY unset).`));
|
|
47
|
+
console.log(chalk_1.default.yellow(' Refusing rather than reporting the configured identity, which belongs to a different\n' +
|
|
48
|
+
' key. Run "ff-cli status" with no key flag if that is the identity you wanted.'));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
25
51
|
if (overrideKey) {
|
|
26
52
|
try {
|
|
27
53
|
console.log(chalk_1.default.green(`Signing identity (did:key) ${(0, signing_identity_1.playlistSigningDidKey)(overrideKey)}`));
|
|
@@ -29,7 +55,7 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
29
55
|
return;
|
|
30
56
|
}
|
|
31
57
|
catch (error) {
|
|
32
|
-
console.log(chalk_1.default.red(
|
|
58
|
+
console.log(chalk_1.default.red(`${explicitKey?.flag ?? '--key'} unusable: ${error.message}`));
|
|
33
59
|
process.exit(1);
|
|
34
60
|
}
|
|
35
61
|
}
|
|
@@ -8,20 +8,13 @@ const commander_1 = require("commander");
|
|
|
8
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
9
|
const feed_server_1 = require("./helpers/feed-server");
|
|
10
10
|
const prompt_1 = require("./helpers/prompt");
|
|
11
|
-
/**
|
|
12
|
-
* `ff-cli unpublish <id-or-url>` — delete a playlist this key owns from a feed.
|
|
13
|
-
*
|
|
14
|
-
* The delete is irreversible in a way most deletes are not: the feed tombstones the id, so the playlist
|
|
15
|
-
* cannot be restored and the id can never be reused. Hence the confirmation, which shows the title and
|
|
16
|
-
* server first, and defaults to no. `-y` skips it for scripts; without a terminal and without `-y` the
|
|
17
|
-
* command refuses rather than assuming consent.
|
|
18
|
-
*/
|
|
19
11
|
exports.unpublishCommand = new commander_1.Command('unpublish')
|
|
20
12
|
.description('Delete a playlist from a feed server (signed delete; owner key required)')
|
|
21
13
|
.argument('<id-or-url>', 'Playlist id, slug, or feed URL')
|
|
22
14
|
.option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
|
|
23
15
|
.option('-y, --yes', 'Skip the confirmation prompt')
|
|
24
16
|
.option('-k, --key <privateKey>', 'Ed25519 private key that signs the delete authorization (overrides config)')
|
|
17
|
+
.option('--key-file <path>', 'Read the key that signs the delete authorization from this file, not the command line')
|
|
25
18
|
.action(async (idOrUrl, options) => {
|
|
26
19
|
try {
|
|
27
20
|
console.log(chalk_1.default.blue('\nUnpublish playlist\n'));
|
|
@@ -46,11 +39,19 @@ exports.unpublishCommand = new commander_1.Command('unpublish')
|
|
|
46
39
|
// The DID displayed below and the key that signs are now the same value.
|
|
47
40
|
//
|
|
48
41
|
// Only the did:key is ever printed; the material never reaches the output from this path.
|
|
42
|
+
//
|
|
43
|
+
// `--key-file` is read here too, in the same step and before the same milestones: a key file that
|
|
44
|
+
// is missing, unreadable, or empty is a credential failure like any other, and it must not be
|
|
45
|
+
// discovered after the operator has approved a delete.
|
|
49
46
|
const { mutationSignerIdentity } = await import('../utilities/feed-mutation.js');
|
|
47
|
+
const { resolveExplicitSigningKey } = await import('../utilities/signing-key-source.js');
|
|
50
48
|
let signerDidKey;
|
|
51
49
|
let signingKey;
|
|
50
|
+
let keySource = 'configured';
|
|
52
51
|
try {
|
|
53
|
-
|
|
52
|
+
const explicitKey = resolveExplicitSigningKey(options);
|
|
53
|
+
keySource = explicitKey?.flag ?? 'configured';
|
|
54
|
+
({ privateKey: signingKey, didKey: signerDidKey } = mutationSignerIdentity(explicitKey?.material, 'delete'));
|
|
54
55
|
}
|
|
55
56
|
catch (error) {
|
|
56
57
|
console.error(chalk_1.default.red('\nCannot sign the delete'));
|
|
@@ -111,7 +112,7 @@ exports.unpublishCommand = new commander_1.Command('unpublish')
|
|
|
111
112
|
}
|
|
112
113
|
const result = await unpublishPlaylist(idOrUrl, selection.url, {
|
|
113
114
|
privateKey: signingKey,
|
|
114
|
-
keySource
|
|
115
|
+
keySource,
|
|
115
116
|
});
|
|
116
117
|
if (result.success) {
|
|
117
118
|
console.log(chalk_1.default.green('Unpublished'));
|
|
@@ -141,7 +141,11 @@ async function fetchStoredPlaylist(feedServerUrl, identifier) {
|
|
|
141
141
|
return stored;
|
|
142
142
|
}
|
|
143
143
|
/**
|
|
144
|
-
* Resolve the key that signs an owner-bound intent, honouring an explicit
|
|
144
|
+
* Resolve the key that signs an owner-bound intent, honouring an explicit key from the command line.
|
|
145
|
+
*
|
|
146
|
+
* `--key-file` material arrives here already read and trimmed by `signing-key-source`, which rejects an
|
|
147
|
+
* empty file there rather than here: the message below is about shell expansion, which is the right
|
|
148
|
+
* diagnosis for a value typed at the prompt and the wrong one for a file that turned out to be empty.
|
|
145
149
|
*
|
|
146
150
|
* **Presence, never truthiness.** `--key ""` — the shape an unset shell variable takes,
|
|
147
151
|
* `--key "$SIGNING_KEY"` with nothing in it — is an override that FAILED, not an absent one. Treating it
|
|
@@ -168,7 +172,8 @@ function resolveMutationSigningKey(override, action) {
|
|
|
168
172
|
const configured = (0, config_1.getPlaylistConfig)().privateKey;
|
|
169
173
|
if (!configured) {
|
|
170
174
|
throw new Error(`No playlist signing key is configured. A ${action} is authorized by a signature, so one is ` +
|
|
171
|
-
'required: run "ff-cli setup", set playlist.privateKey in config.json, or pass --key
|
|
175
|
+
'required: run "ff-cli setup", set playlist.privateKey in config.json, or pass --key / ' +
|
|
176
|
+
'--key-file for this run.');
|
|
172
177
|
}
|
|
173
178
|
return configured;
|
|
174
179
|
}
|
|
@@ -235,20 +240,26 @@ async function storedOwnership(stored) {
|
|
|
235
240
|
}
|
|
236
241
|
return { declared, proven };
|
|
237
242
|
}
|
|
243
|
+
/** How a private key is named as the argument to the flag it arrived on. */
|
|
244
|
+
function flagArgument(keySource) {
|
|
245
|
+
return keySource === '--key-file' ? 'file holding the private key' : 'private key';
|
|
246
|
+
}
|
|
238
247
|
/** How to describe the identity in a refusal, given where it came from. */
|
|
239
248
|
function identityLabel(keySource) {
|
|
240
|
-
return keySource === '
|
|
241
|
-
? '
|
|
242
|
-
:
|
|
249
|
+
return keySource === 'configured'
|
|
250
|
+
? 'Your configured identity:'
|
|
251
|
+
: `The identity you passed with ${keySource}:`;
|
|
243
252
|
}
|
|
244
253
|
/** What to do about it, given where it came from. */
|
|
245
254
|
function retryAdvice(keySource, plural) {
|
|
246
255
|
const which = plural ? 'one of the keys listed above' : 'the key listed above';
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
`
|
|
250
|
-
|
|
251
|
-
|
|
256
|
+
if (keySource === 'configured') {
|
|
257
|
+
return (` Point playlist.privateKey at ${which}, or pass one for this run with --key (confirm any\n` +
|
|
258
|
+
` key's identity with "ff-cli status --key <private key>").`);
|
|
259
|
+
}
|
|
260
|
+
const argument = flagArgument(keySource);
|
|
261
|
+
return (` Run it again with an owner key: ${keySource} <${argument} for ${which}>.\n` +
|
|
262
|
+
` (Confirm which identity a key carries with "ff-cli status ${keySource} <${argument}>".)`);
|
|
252
263
|
}
|
|
253
264
|
/**
|
|
254
265
|
* Refuse locally when the signing key cannot authorize a mutation on the stored playlist.
|
|
@@ -319,7 +330,9 @@ async function ownershipPreflight(stored, signerDidKey, action, keySource = 'con
|
|
|
319
330
|
` over the document as published.`,
|
|
320
331
|
};
|
|
321
332
|
}
|
|
322
|
-
const source = keySource === '
|
|
333
|
+
const source = keySource === 'configured'
|
|
334
|
+
? 'The configured signing key is'
|
|
335
|
+
: `The key you passed with ${keySource} is`;
|
|
323
336
|
return {
|
|
324
337
|
error: `${source} not an owner of this playlist, so it cannot ${verb} it.`,
|
|
325
338
|
message: `Only a key the stored playlist names in curators[] AND that signed it as "${exports.OWNER_ROLE}" can\n` +
|
|
@@ -381,8 +394,8 @@ function describeFeedMutationError(error, action, keySource = 'configured') {
|
|
|
381
394
|
return {
|
|
382
395
|
error: `${verb} refused: the feed saw no signatures on the request.`,
|
|
383
396
|
message: `Every mutating request is authorized by the signatures in its body — there is no API key.\n` +
|
|
384
|
-
(keySource
|
|
385
|
-
? ` The key passed with
|
|
397
|
+
(keySource !== 'configured'
|
|
398
|
+
? ` The key passed with ${keySource} produced no signature the feed could read, which should not\n` +
|
|
386
399
|
` happen once it has been accepted locally — please report this.\n`
|
|
387
400
|
: ` This usually means no signing key is configured: run "ff-cli status" to check, and\n` +
|
|
388
401
|
` "ff-cli setup" to generate one.\n`) +
|
|
@@ -395,9 +408,9 @@ function describeFeedMutationError(error, action, keySource = 'configured') {
|
|
|
395
408
|
// remaining causes are the feed disagreeing about the stored document, or a replace touching the
|
|
396
409
|
// owner set. Say that the feed refused, and that the local check disagreed.
|
|
397
410
|
if (status === 403) {
|
|
398
|
-
const which = keySource === '
|
|
399
|
-
? 'the
|
|
400
|
-
:
|
|
411
|
+
const which = keySource === 'configured'
|
|
412
|
+
? 'the configured signing key is'
|
|
413
|
+
: `the key you passed with ${keySource} is`;
|
|
401
414
|
return {
|
|
402
415
|
error: `${verb} refused by the feed: it did not accept the signing key as an owner.`,
|
|
403
416
|
message: `The local check disagreed — ${which} named in the stored playlist's curators[]\n` +
|
|
@@ -127,7 +127,7 @@ async function replacePlaylist(filePath, feedServerUrl, options = {}) {
|
|
|
127
127
|
// Where the identity came from, resolved before the first request so every refusal — local or from
|
|
128
128
|
// the feed — names the key the operator actually used rather than a config file this run may not
|
|
129
129
|
// have read.
|
|
130
|
-
const keySource = options.keySource ?? (options.privateKey !== undefined ? '
|
|
130
|
+
const keySource = options.keySource ?? (options.privateKey !== undefined ? '--key' : 'configured');
|
|
131
131
|
// Presence, not truthiness: `--key ""` is an override that failed to expand, and falling back to the
|
|
132
132
|
// configured key would sign someone else's replacement into place.
|
|
133
133
|
let privateKey;
|
|
@@ -56,7 +56,7 @@ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
|
|
|
56
56
|
// Where the identity came from, resolved before the first request so every refusal — local or from
|
|
57
57
|
// the feed — names the key the operator actually used rather than a config file this run may not
|
|
58
58
|
// have read.
|
|
59
|
-
const keySource = options.keySource ?? (options.privateKey !== undefined ? '
|
|
59
|
+
const keySource = options.keySource ?? (options.privateKey !== undefined ? '--key' : 'configured');
|
|
60
60
|
// Resolve and validate together: `--key ""` is a failed override, not an absent one, and must never
|
|
61
61
|
// fall through to the configured key on an operation that tombstones an id.
|
|
62
62
|
let privateKey;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Where an explicit signing key came from: `--key` on the command line, or `--key-file` on disk.
|
|
4
|
+
*
|
|
5
|
+
* `--key` puts a private key in two places nobody chose to put it: the shell history file, and the
|
|
6
|
+
* process list, where any local user can read it for as long as the command runs. Neither is something
|
|
7
|
+
* the operator can take back afterwards, and an Ed25519 signing key that has been disclosed is not
|
|
8
|
+
* repairable — the identity it asserts is the identity, and a playlist's owner set is immutable. So
|
|
9
|
+
* every command that accepts `--key` also accepts `--key-file <path>`, which reads the same material
|
|
10
|
+
* from a file the operator controls.
|
|
11
|
+
*
|
|
12
|
+
* The two flags are mutually exclusive rather than ordered. A precedence rule would have to pick a
|
|
13
|
+
* winner when they disagree, and the loser is silently ignored — which is exactly the class of failure
|
|
14
|
+
* the empty-`--key` fix removed: a key the operator believes was used and was not.
|
|
15
|
+
*
|
|
16
|
+
* Reading the file is deliberately *not* gated on its permissions. A mode check would refuse work on
|
|
17
|
+
* key files that are perfectly appropriate — a file inside a per-user directory, a path on a tmpfs
|
|
18
|
+
* mounted for one user, a Windows ACL the mode bits cannot describe — and it would still not prove the
|
|
19
|
+
* file is private, because the directory above it decides who can reach the name at all. The
|
|
20
|
+
* documentation says to keep the file private; the CLI does not pretend to be able to check it.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.explicitSigningKeyFlag = explicitSigningKeyFlag;
|
|
24
|
+
exports.readSigningKeyFile = readSigningKeyFile;
|
|
25
|
+
exports.resolveExplicitSigningKey = resolveExplicitSigningKey;
|
|
26
|
+
const node_fs_1 = require("node:fs");
|
|
27
|
+
/**
|
|
28
|
+
* Which key flag the command was given, without reading anything.
|
|
29
|
+
*
|
|
30
|
+
* Split out from the resolution below so a command that is going to *refuse* an explicit key — a plain
|
|
31
|
+
* `publish` signs nothing at request time — can name the flag the operator actually typed without
|
|
32
|
+
* opening their key file to do it.
|
|
33
|
+
*
|
|
34
|
+
* Presence, never truthiness: `--key ""` and `--key-file ""` are both what an unset shell variable
|
|
35
|
+
* expands to, and both are overrides that failed rather than overrides that are absent.
|
|
36
|
+
*
|
|
37
|
+
* @param options - The command's parsed options
|
|
38
|
+
* @returns The flag used, or `undefined` when neither was given
|
|
39
|
+
* @throws Error when both flags are present
|
|
40
|
+
*/
|
|
41
|
+
function explicitSigningKeyFlag(options) {
|
|
42
|
+
const hasKey = options.key !== undefined;
|
|
43
|
+
const hasKeyFile = options.keyFile !== undefined;
|
|
44
|
+
if (hasKey && hasKeyFile) {
|
|
45
|
+
throw new Error('--key and --key-file both name a signing key, and nothing here can tell which one you meant. ' +
|
|
46
|
+
'Pass exactly one: --key for material on the command line, --key-file for a path to read it from.');
|
|
47
|
+
}
|
|
48
|
+
if (hasKeyFile) {
|
|
49
|
+
return '--key-file';
|
|
50
|
+
}
|
|
51
|
+
return hasKey ? '--key' : undefined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read key material out of a key file.
|
|
55
|
+
*
|
|
56
|
+
* The contents are trimmed, so a file written by `printf`, by an editor that appends a newline, or by a
|
|
57
|
+
* password manager's export all behave the same. What comes back then goes through the *same*
|
|
58
|
+
* normalization `--key` does — base64 PKCS#8 DER, a 32-byte seed as hex or base64, or PEM — because a
|
|
59
|
+
* key file is a delivery mechanism, not a second key format.
|
|
60
|
+
*
|
|
61
|
+
* An empty or whitespace-only file is rejected here rather than passed on. Every other outcome would be
|
|
62
|
+
* a silent fallback to the configured key: `sign` and the feed mutations would refuse an empty `--key`
|
|
63
|
+
* with a message about shell expansion that is wrong for a file, and `status` would quietly report the
|
|
64
|
+
* configured identity as though it were the file's. That fallback is the bug the empty-`--key` fix
|
|
65
|
+
* removed, and it is worse from a file, because a file that reads as empty (truncated, still being
|
|
66
|
+
* written, the wrong path in a directory of similar names) looks nothing like a typo at the prompt.
|
|
67
|
+
*
|
|
68
|
+
* Every argument is opened. Deciding in advance that something "looks like a key" and refusing to try
|
|
69
|
+
* cost real paths — `/run/BEGIN/secrets/owner.key`, a content-addressed 64-hex filename — and bought
|
|
70
|
+
* nothing, because opening a wrong path is free. What is not free is *printing* one: a private key
|
|
71
|
+
* that reaches a terminal is in scrollback and in whatever ships those logs onward, and it cannot be
|
|
72
|
+
* rotated out of a document that already names it. So the guarantee lives entirely in what may be said
|
|
73
|
+
* about a failure; see `safePathLabel`. Existence is also the only thing separating a key typed into
|
|
74
|
+
* the path from a file genuinely named after a hex digest, and that cannot be known without looking.
|
|
75
|
+
*
|
|
76
|
+
* @param path - Value of `--key-file` exactly as the command received it
|
|
77
|
+
* @returns Trimmed key material
|
|
78
|
+
* @throws Error when the path is empty, cannot be read, or holds no key material
|
|
79
|
+
*/
|
|
80
|
+
function readSigningKeyFile(path) {
|
|
81
|
+
if (path.trim().length === 0) {
|
|
82
|
+
throw new Error('The --key-file path is empty. This usually means a shell variable did not expand (for example ' +
|
|
83
|
+
'--key-file "$KEY_PATH" with KEY_PATH unset). Refusing rather than falling back to the ' +
|
|
84
|
+
'configured key, which would sign under an identity you did not choose.');
|
|
85
|
+
}
|
|
86
|
+
// The path is used exactly as given, NOT trimmed. Trimming was only ever a convenience, and it is
|
|
87
|
+
// the wrong one here: it silently opens `owner.key` for `--key-file "owner.key "`, so a file that
|
|
88
|
+
// is not the one named decides what signs. The trim above answers a different question — whether
|
|
89
|
+
// anything was passed at all.
|
|
90
|
+
let contents;
|
|
91
|
+
try {
|
|
92
|
+
contents = (0, node_fs_1.readFileSync)(path, 'utf-8');
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
throw new Error(describeKeyFileReadFailure(path, error));
|
|
96
|
+
}
|
|
97
|
+
const material = contents.trim();
|
|
98
|
+
if (material.length === 0) {
|
|
99
|
+
throw new Error(`The key file at ${safePathLabel(path)} holds no key material. Refusing rather than falling ` +
|
|
100
|
+
'back to the configured key: a signature made under an identity you did not choose is not ' +
|
|
101
|
+
'something a publish or a delete can be taken back from.');
|
|
102
|
+
}
|
|
103
|
+
return material;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Whether a value is, on its own, a complete Ed25519 key encoding.
|
|
107
|
+
*
|
|
108
|
+
* Complete is the whole point. An earlier version treated any `BEGIN` substring and any key-shaped
|
|
109
|
+
* last segment as reason enough to refuse before opening, which made `/run/BEGIN/secrets/owner.key`
|
|
110
|
+
* and a content-addressed 64-hex filename unusable — real paths, refused for looking like something
|
|
111
|
+
* they were not, with no way to say otherwise. The shapes below are the forms a whole key takes and
|
|
112
|
+
* nothing else: a PEM block carries both its header AND the line breaks a real block has, a hex seed
|
|
113
|
+
* is 64 characters of nothing but hex, and base64 is judged by what it decodes to.
|
|
114
|
+
*/
|
|
115
|
+
function isWholeKeyEncoding(value) {
|
|
116
|
+
if (value.includes('BEGIN') && /[\r\n]/.test(value)) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (/^(0x)?[0-9a-fA-F]{64,}$/.test(value)) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
// Decided by DECODED LENGTH rather than by charset alone: `/` and `+` are legal base64 and most
|
|
123
|
+
// real PKCS#8 keys contain a `/`, so a charset test that read that as a directory separator would
|
|
124
|
+
// let the common case through. 32 bytes is a raw Ed25519 seed; 48 is PKCS#8 for one.
|
|
125
|
+
if (/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
|
|
126
|
+
const decodedLength = Buffer.from(value, 'base64').length;
|
|
127
|
+
if (decodedLength === 32 || decodedLength === 48) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
/** Whether the argument is a bare key: a complete encoding with no path structure around it. */
|
|
134
|
+
function isBareKeyMaterial(value) {
|
|
135
|
+
return !/[/\\]/.test(value) && isWholeKeyEncoding(value);
|
|
136
|
+
}
|
|
137
|
+
/** Whether any path segment is a complete key encoding — a key with a directory prefix on it. */
|
|
138
|
+
function anySegmentIsKeyEncoding(value) {
|
|
139
|
+
return value.split(/[/\\]/).some((segment) => isWholeKeyEncoding(segment));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* How to refer to a `--key-file` argument in a message, without ever printing key material.
|
|
143
|
+
*
|
|
144
|
+
* This is the guarantee, and it is deliberately separate from the decision to open. Opening a wrong
|
|
145
|
+
* path costs nothing; printing one can disclose a private key that cannot be rotated out of a document
|
|
146
|
+
* that already names it. So everything is opened, and this decides what may be said afterwards.
|
|
147
|
+
*
|
|
148
|
+
* Length carries most of it: no complete key encoding is under 40 characters — a 32-byte seed is 64
|
|
149
|
+
* hex or 44 base64 characters — so a short argument cannot be a whole key, and a partial one is not a
|
|
150
|
+
* key. The segment test then covers a key wearing a directory prefix, which length alone would pass
|
|
151
|
+
* only for something already too long to print.
|
|
152
|
+
*
|
|
153
|
+
* @param path - Value of `--key-file` exactly as the command received it
|
|
154
|
+
* @returns The path itself when it is provably safe to print, or a description of it
|
|
155
|
+
*/
|
|
156
|
+
function safePathLabel(path) {
|
|
157
|
+
const value = path.trim();
|
|
158
|
+
if (value.length < 40 && !anySegmentIsKeyEncoding(value)) {
|
|
159
|
+
return path;
|
|
160
|
+
}
|
|
161
|
+
return 'the path given (not repeated here, in case it is key material)';
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Turn a read failure into a sentence that says what to do about it.
|
|
165
|
+
*
|
|
166
|
+
* The raw errno text ("EISDIR: illegal operation on a directory, read") reads like a bug in the CLI
|
|
167
|
+
* rather than a wrong path, and it is the same three mistakes every time: the file is not there, it is
|
|
168
|
+
* not readable, or the path names a directory.
|
|
169
|
+
*
|
|
170
|
+
* The underlying `Error.message` is never interpolated — only its `code`. Node builds that message by
|
|
171
|
+
* appending the path it was given, so passing it through would echo the argument a second time, in a
|
|
172
|
+
* form no caller here controls.
|
|
173
|
+
*/
|
|
174
|
+
function describeKeyFileReadFailure(path, error) {
|
|
175
|
+
const code = error?.code;
|
|
176
|
+
const value = path.trim();
|
|
177
|
+
// A bare, complete key with nothing of a path about it, and no file of that name. `--key-file
|
|
178
|
+
// "$SIGNING_KEY"` is one keystroke from `--key "$SIGNING_KEY"`, and it is the natural typo for
|
|
179
|
+
// someone moving off `--key`, so say what the mistake was. Existence is what separates this from a
|
|
180
|
+
// file someone genuinely named after a hex digest — which is why the open is attempted first rather
|
|
181
|
+
// than guessed at.
|
|
182
|
+
if (isBareKeyMaterial(value)) {
|
|
183
|
+
return ('The --key-file argument looks like a key rather than a path, and no file of that name exists. ' +
|
|
184
|
+
'It is not repeated here. Point --key-file at the file that holds the key, or pass the key ' +
|
|
185
|
+
'itself with --key.');
|
|
186
|
+
}
|
|
187
|
+
const label = safePathLabel(path);
|
|
188
|
+
// When the argument cannot be printed, the errno is the whole message. Naming which failure it was
|
|
189
|
+
// is still worth saying — "there is nothing there" and "you may not read it" send an operator to
|
|
190
|
+
// different places — but nothing about the value itself may appear.
|
|
191
|
+
if (label !== path) {
|
|
192
|
+
return (`The --key-file argument could not be read (${code ?? 'read failed'}) and is not repeated ` +
|
|
193
|
+
'here: it may be key material rather than a path. Check the path you passed.');
|
|
194
|
+
}
|
|
195
|
+
if (code === 'ENOENT') {
|
|
196
|
+
return `No key file at ${label}.`;
|
|
197
|
+
}
|
|
198
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
199
|
+
return `Cannot read the key file at ${label}: permission denied (${code}).`;
|
|
200
|
+
}
|
|
201
|
+
if (code === 'EISDIR') {
|
|
202
|
+
return `${label} is a directory, not a key file.`;
|
|
203
|
+
}
|
|
204
|
+
return `Cannot read the key file at ${label} (${code ?? 'read failed'}).`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Resolve the explicit signing key a command was given, from either flag.
|
|
208
|
+
*
|
|
209
|
+
* Commands call this before anything else — before a feed is chosen, before a playlist is looked up,
|
|
210
|
+
* before a destructive action is confirmed — so an unreadable file fails while the only thing it can
|
|
211
|
+
* cost is the run itself.
|
|
212
|
+
*
|
|
213
|
+
* `--key` material is returned exactly as typed, including when it is empty: the commands already
|
|
214
|
+
* refuse an empty `--key` with a message about shell expansion, and that message is the right one for
|
|
215
|
+
* a value that came from the command line.
|
|
216
|
+
*
|
|
217
|
+
* @param options - The command's parsed options
|
|
218
|
+
* @returns The explicit key and its flag, or `undefined` when neither flag was given
|
|
219
|
+
* @throws Error when both flags are present, or when a key file cannot be read or is empty
|
|
220
|
+
*/
|
|
221
|
+
function resolveExplicitSigningKey(options) {
|
|
222
|
+
const flag = explicitSigningKeyFlag(options);
|
|
223
|
+
if (flag === undefined) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
if (flag === '--key-file') {
|
|
227
|
+
return { material: readSigningKeyFile(options.keyFile), flag };
|
|
228
|
+
}
|
|
229
|
+
return { material: options.key, flag };
|
|
230
|
+
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -39,7 +39,7 @@ Optional settings used where headless/browser‑like behavior is needed.
|
|
|
39
39
|
|
|
40
40
|
Used for signing DP‑1 playlists.
|
|
41
41
|
|
|
42
|
-
- `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures, and as the default signing key for the owner-bound feed mutations (`publish --replace`, `unpublish`), which sign an authorization intent at request time. Those commands and `sign` all take `-k, --key` to override it for a single run. The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `play` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
|
|
42
|
+
- `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures, and as the default signing key for the owner-bound feed mutations (`publish --replace`, `unpublish`), which sign an authorization intent at request time. Those commands and `sign` all take `-k, --key` or `--key-file <path>` to override it for a single run — see [Where the signing key can come from](#where-the-signing-key-can-come-from). The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `play` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
|
|
43
43
|
|
|
44
44
|
**Signing and key encoding:** Signing paths (`sign`, deterministic `build` when configured, and `-k/--key` overrides) accept the private key in any of these encodings:
|
|
45
45
|
|
|
@@ -61,10 +61,64 @@ Used for signing DP‑1 playlists.
|
|
|
61
61
|
|
|
62
62
|
To read the `did:key` your signatures will carry, run `ff-cli status`. It reports the identity from
|
|
63
63
|
`PLAYLIST_PRIVATE_KEY` even when no config file exists yet, and `ff-cli status --key <privateKey>`
|
|
64
|
-
reports it for an explicit key — the one `sign
|
|
64
|
+
(or `--key-file <path>`) reports it for an explicit key — the one `sign` would use with the same flag.
|
|
65
65
|
|
|
66
66
|
- `playlist.role` (string): DP-1 signing role used by `ff-cli sign`. The shipped `config.json.example` sets `curator`, because a document signed as `agent` is ownerless to a role-aware feed and `ff-cli publish` refuses it. The code default when the key is omitted entirely is still `agent`. You can also set this via `PLAYLIST_ROLE` in `.env`. Guided `ff-cli setup`, `config validate`, and `sign --role` only accept the usual DP-1 signing roles (`agent`, `feed`, `curator`, `institution`, `licensor`). It does **not** apply to `find` and `build`, which always sign as `curator` — see [Signing role and ownership](#signing-role-and-ownership).
|
|
67
67
|
|
|
68
|
+
### Where the signing key can come from
|
|
69
|
+
|
|
70
|
+
Three sources, in this order of precedence:
|
|
71
|
+
|
|
72
|
+
1. **`-k, --key <privateKey>` or `--key-file <path>`** on the command line — `sign`, `status`,
|
|
73
|
+
`unpublish`, and `publish --replace` all take both.
|
|
74
|
+
2. **`playlist.privateKey`** in `config.json`.
|
|
75
|
+
3. **`PLAYLIST_PRIVATE_KEY`** in the environment or in `.env`.
|
|
76
|
+
|
|
77
|
+
The file beats the environment, matching the repo-wide `config.json` > `.env` > defaults rule — with
|
|
78
|
+
one exception: a `playlist.privateKey` that is empty or still holds a `YOUR_`/`your_` sample
|
|
79
|
+
placeholder is treated as **unset**, so `PLAYLIST_PRIVATE_KEY` is used instead. Without that, an
|
|
80
|
+
untouched `config init` would beat a perfectly good environment key and every signing path would fail
|
|
81
|
+
on a key you never chose.
|
|
82
|
+
|
|
83
|
+
An explicit key wins for that one run only; nothing is written back. All three accept the same
|
|
84
|
+
encodings listed above, and the CLI normalizes whichever you supply before signing, so a key file is a
|
|
85
|
+
delivery mechanism rather than a fourth key format.
|
|
86
|
+
|
|
87
|
+
**Prefer `--key-file` to `--key`.** `--key` writes your private key into your shell history file and
|
|
88
|
+
exposes it in the process list, where any local user can read it for as long as the command runs.
|
|
89
|
+
Neither disclosure can be undone: an Ed25519 signing key *is* the identity it asserts, and a playlist's
|
|
90
|
+
`curators[]` owner set is immutable, so a leaked key cannot be rotated out of a document that already
|
|
91
|
+
names it. `--key-file` keeps the material in a file you control:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
ff-cli status --key-file ~/.config/ff-cli/signing.key # whose key is this?
|
|
95
|
+
ff-cli sign playlist.json -r curator --key-file ~/.config/ff-cli/signing.key
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Keep the key file private** — `chmod 600` it, in a directory only you can enter. The CLI does not
|
|
99
|
+
check its permissions and will not refuse a world-readable one: a mode check cannot see the directory
|
|
100
|
+
above the file, cannot describe a Windows ACL, and would refuse paths that are perfectly appropriate,
|
|
101
|
+
so it would be ceremony rather than protection.
|
|
102
|
+
|
|
103
|
+
The rules on both flags:
|
|
104
|
+
|
|
105
|
+
- **Passing both is an error**, not a precedence rule. A precedence rule has to silently ignore one of
|
|
106
|
+
the two keys you supplied, and a key you believe was used and was not is the failure this CLI keeps
|
|
107
|
+
removing.
|
|
108
|
+
- **An empty `--key`** — what `--key "$SIGNING_KEY"` becomes when the variable is unset — is rejected,
|
|
109
|
+
never treated as absent.
|
|
110
|
+
- **A `--key-file` that is missing, unreadable, or holds no key material** is rejected the same way.
|
|
111
|
+
Neither ever falls back to the configured key: signing under an identity you did not choose is not
|
|
112
|
+
something a publish or a delete can be taken back from.
|
|
113
|
+
- **A `--key-file` argument is always opened, whatever it looks like.** A directory really can be
|
|
114
|
+
called `BEGIN`, and a key file really can be named after a content hash. What is gated is not the
|
|
115
|
+
open but the *message*: a failure names the path only when it is short and no part of it is a
|
|
116
|
+
complete key encoding, and otherwise says it could not be read without repeating it. Typing the key
|
|
117
|
+
where the path goes — one keystroke from `--key` — is reported as that mistake, by name, with the
|
|
118
|
+
value withheld.
|
|
119
|
+
- **The key is never echoed**, in any error, in any encoding — nor is a `--key-file` argument that may
|
|
120
|
+
be one.
|
|
121
|
+
|
|
68
122
|
### Generate an Ed25519 private key
|
|
69
123
|
|
|
70
124
|
The simplest path is `ff-cli setup`, which generates a key for you (base64 PKCS#8 DER) and writes it to your config. To generate one yourself:
|
|
@@ -143,8 +197,8 @@ signatures in the request body, and an `apiKey` left in an existing config is ig
|
|
|
143
197
|
|
|
144
198
|
`playlist.privateKey` is needed only where the CLI itself has to sign at request time — `publish
|
|
145
199
|
--replace` and `unpublish`, which sign the owner-bound intent that authorizes the write. Both accept
|
|
146
|
-
`-k, --key` to use a different key for one run, so a second owner identity does
|
|
147
|
-
file. A plain
|
|
200
|
+
`-k, --key` or `--key-file <path>` to use a different key for one run, so a second owner identity does
|
|
201
|
+
not require editing this file. A plain
|
|
148
202
|
`publish` signs nothing: it uploads the `signatures[]` envelope the document already carries, so it
|
|
149
203
|
works with no key configured at all (the document must have been signed at some point, by
|
|
150
204
|
`ff-cli sign` or by whoever produced it, but not by this command).
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -789,9 +789,9 @@ a new playlist instead of trying to recreate it.
|
|
|
789
789
|
### Signing with a key other than the configured one
|
|
790
790
|
|
|
791
791
|
The configured `playlist.privateKey` is the default, not the only option. `unpublish` and
|
|
792
|
-
`publish --replace` take `-k, --key
|
|
793
|
-
default both for the intent signature and for the local ownership check —
|
|
794
|
-
no longer means editing `config.json`:
|
|
792
|
+
`publish --replace` take `-k, --key` and `--key-file <path>`, like `ff-cli sign` and `ff-cli status`,
|
|
793
|
+
and either one overrides that default both for the intent signature and for the local ownership check —
|
|
794
|
+
so holding a second owner key no longer means editing `config.json`:
|
|
795
795
|
|
|
796
796
|
```bash
|
|
797
797
|
ff-cli unpublish <id> -s 0 --key <private key for a stored owner>
|
|
@@ -804,8 +804,36 @@ confirmation and under `-y`, so the identity that performs an irreversible delet
|
|
|
804
804
|
record. It resolves that key once, before the lookup and before the prompt, and signs with the same
|
|
805
805
|
material — editing `config.json` while a confirmation is open cannot change who the delete runs as.
|
|
806
806
|
|
|
807
|
-
|
|
808
|
-
|
|
807
|
+
#### Keeping the key off the command line
|
|
808
|
+
|
|
809
|
+
`--key` puts the private key in your shell history file and in the process list, where any local user
|
|
810
|
+
can read it while the command runs. `--key-file <path>` reads the same material from a file instead, on
|
|
811
|
+
all four commands, with the same key encodings:
|
|
812
|
+
|
|
813
|
+
```bash
|
|
814
|
+
ff-cli status --key-file ~/.config/ff-cli/owner.key # whose key is this?
|
|
815
|
+
ff-cli sign playlist.json -r curator --key-file ~/.config/ff-cli/owner.key
|
|
816
|
+
ff-cli unpublish <id> -s 0 --key-file ~/.config/ff-cli/owner.key
|
|
817
|
+
ff-cli publish playlist.json --replace -s 0 --key-file ~/.config/ff-cli/owner.key
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
Keep the file private (`chmod 600`, in a directory only you can enter) — the CLI does not check its
|
|
821
|
+
permissions. Passing `--key` and `--key-file` together is an error rather than a precedence rule, and a
|
|
822
|
+
key file that is missing, unreadable, or empty is refused rather than quietly falling back to the
|
|
823
|
+
configured key:
|
|
824
|
+
|
|
825
|
+
```
|
|
826
|
+
$ ff-cli unpublish <id> -y --key-file ./owner.key # file was truncated
|
|
827
|
+
|
|
828
|
+
Cannot sign the delete
|
|
829
|
+
The key file at ./owner.key holds no key material. Refusing rather than falling back to
|
|
830
|
+
the configured key: a signature made under an identity you did not choose is not something
|
|
831
|
+
a publish or a delete can be taken back from.
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
A refusal names the key you actually used. If it came from `--key`, the retry says `--key`; if it came
|
|
835
|
+
from `--key-file`, the retry says `--key-file`; it will not send you to `playlist.privateKey` for a run
|
|
836
|
+
that never read it.
|
|
809
837
|
|
|
810
838
|
An empty `--key` is **rejected**, not ignored:
|
|
811
839
|
|
|
@@ -819,8 +847,9 @@ Cannot sign the delete
|
|
|
819
847
|
delete can be taken back from.
|
|
820
848
|
```
|
|
821
849
|
|
|
822
|
-
A plain `publish` **refuses** `--key` rather than ignoring
|
|
823
|
-
flag would do nothing. `ff-cli fetch` has
|
|
850
|
+
A plain `publish` **refuses** `--key` and `--key-file` rather than ignoring them: it signs nothing at
|
|
851
|
+
request time, so the flag would do nothing. `ff-cli fetch` has neither for the same reason — it is a
|
|
852
|
+
read.
|
|
824
853
|
|
|
825
854
|
### When ownership cannot be proved
|
|
826
855
|
|
package/docs/PROJECT_SPEC.md
CHANGED
|
@@ -192,7 +192,9 @@ created, signatures }`, with a `payloadHash` for replace — whose `created` mus
|
|
|
192
192
|
freshness window; the intent exists because a document's own signatures are public via `GET` and could
|
|
193
193
|
otherwise be replayed to roll a resource back. The CLI proves ownership before it signs anything: it
|
|
194
194
|
`GET`s the stored playlist and requires the signing key — the configured `playlist.privateKey` unless
|
|
195
|
-
`-k, --key` overrides it for that command
|
|
195
|
+
`-k, --key` or `--key-file <path>` overrides it for that command, the latter keeping the key out of the
|
|
196
|
+
shell history and the process list (`CONFIGURATION.md`, "Where the signing key can come from") — to be
|
|
197
|
+
both named in the stored `curators[]` and the signer of
|
|
196
198
|
a cryptographically valid `curator`-role signature over that stored document. Being
|
|
197
199
|
named is a claim; the owner-role signature is the proof, and checking only the claim is what would let a
|
|
198
200
|
legacy `agent`-signed document pass preflight and come back as a `403` reported as a missing
|
|
@@ -392,10 +394,17 @@ and nothing else. `enrich` is the repair path for those.
|
|
|
392
394
|
a curator-restricted playlist is never briefly world-readable mid-write. The
|
|
393
395
|
destination's owner and group are carried across too, since a replacement is
|
|
394
396
|
a new inode and would otherwise take this process's ownership — silently
|
|
395
|
-
reassigning a shared playlist.
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
397
|
+
reassigning a shared playlist. Two things clear set-user-ID and set-group-ID
|
|
398
|
+
— the chown, and an unprivileged write to a regular file — so the mode is
|
|
399
|
+
restored and verified against the inode after each of them, the second time
|
|
400
|
+
with nothing left before the rename that could change it. Where the ownership
|
|
401
|
+
or the mode cannot be reproduced, the in-place replacement is refused and
|
|
402
|
+
`--output` is offered instead. Access-control
|
|
403
|
+
lists are not preserved: a replacement is a new inode, so its ACL comes from
|
|
404
|
+
the directory's default rather than from the file being replaced, and mode
|
|
405
|
+
bits cannot carry named entries across on any platform — a playlist whose
|
|
406
|
+
access depends on an ACL should be enriched through `--output` to a fresh
|
|
407
|
+
name in a directory whose access is what the result should have.
|
|
399
408
|
- `--output` names a file the caller expects to exist afterwards, so it is
|
|
400
409
|
written even when nothing was enriched. A no-op without `--output` writes
|
|
401
410
|
nothing rather than rewriting the input for no gain.
|
package/docs/README.md
CHANGED
|
@@ -19,6 +19,19 @@ npm i -g @feralfile/cli
|
|
|
19
19
|
ff-cli setup
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
Unattended provisioning takes the same flags without prompting. Pass an existing signing key with
|
|
23
|
+
`--key-file <path>` rather than `--key`, which puts the key in the process list of a machine nobody is
|
|
24
|
+
watching and in whatever shell or CI log recorded the command:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
ff-cli setup --non-interactive --key-file /run/secrets/ff-signing.key \
|
|
28
|
+
--role curator --device-host http://192.168.1.50:1111 --device-name studio
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`--key` and `--key-file` together is an error, and an empty or unreadable key file is refused rather
|
|
32
|
+
than quietly generating a new identity for the machine. See
|
|
33
|
+
[Where the signing key can come from](./CONFIGURATION.md#where-the-signing-key-can-come-from).
|
|
34
|
+
|
|
22
35
|
See the full configuration reference here: `./CONFIGURATION.md`.
|
|
23
36
|
|
|
24
37
|
During setup, you can pick FF1 devices to add. Use `ff-cli device add` to add more devices later, and `ff-cli device list` to see what's configured. The first device is the default for `play` commands (override with `-d`).
|
|
@@ -97,7 +110,8 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
|
|
|
97
110
|
- `validate <file-or-url>` – Validate playlist structure only
|
|
98
111
|
- `verify <file-or-url>` – Validate structure and verify signatures. On failure, the CLI labels structure issues separately from signature verification. dp1-js uses `--public-key` (or a key derived from `playlist.privateKey` / `PLAYLIST_PRIVATE_KEY` when omitted) **only** for legacy flat `signature` verification; DP-1 v1.1.0 `signatures[]` envelopes are verified without relying on that argument. If deriving or normalizing key material fails, the CLI prints a warning on stderr and continues without it (legacy verification still requires a usable key when the playlist uses a flat `signature`). The derived key is emitted as PEM. Supported key forms: hex with optional `0x`, PEM, or 32-byte raw public key as hex or base64
|
|
99
112
|
- `sign <file>` – Sign playlist with a DP-1 v1.1.0 multi-signature envelope (private key string is forwarded to **`dp1-js`**; same hex or base64 PKCS#8 DER forms as `playlist.privateKey` in `./CONFIGURATION.md`). The command verifies the final envelope before writing output and refuses to persist tampered or otherwise unverifiable `signatures[]`.
|
|
100
|
-
- Options: `-k, --key <privateKey>`, `-r, --role <role>`, `-o, --output <file>`, `--replace-signatures`, `--force`
|
|
113
|
+
- Options: `-k, --key <privateKey>`, `--key-file <path>`, `-r, --role <role>`, `-o, --output <file>`, `--replace-signatures`, `--force`
|
|
114
|
+
- `--key-file <path>` reads the key from a file instead of the command line, which is where `--key` leaves it in your shell history and in the process list. Same key encodings, same normalization; keep the file private, since the CLI does not check its permissions. `--key` and `--key-file` together is an error, and an empty or unreadable key file is refused rather than falling back to the configured key
|
|
101
115
|
- **It overwrites only a document it has read.** Before writing, the destination is read back and compared with the document being signed; if it holds something else, the run is refused rather than destroying a file the command never inspected. `--force` allows that overwrite, and the report names the file it replaced. A destination holding the *same* document — a copy of the input — is treated as an in-place run, so it is refused only when it would discard a still-valid signature from another key, and `--force` does not override that one. The read and the overwrite are not atomic, so an editor writing in the same instant can still lose its change, as with any in-place tool — a fresh `--output` name is the write that cannot collide. A destination that already exists needs read permission
|
|
102
116
|
- Signing **appends** by default, so repeated runs accumulate endorsements. That is only correct while the signed content is unchanged: editing a signed playlist moves bytes the earlier signatures cover, and the envelope check then refuses to write the result. `--replace-signatures` discards every existing entry — including a feed's own co-signature — and signs the document as it stands. It is the required step before `publish --replace`
|
|
103
117
|
- **An in-place run refuses when it would discard a still-valid signature from another key**, since only its holder could make that signature again: write the result elsewhere with `-o, --output`, which leaves the original where it is. The refusal is about what cannot be recovered — an in-place run still proceeds when the only entries dropped are your own, or ones that no longer verify
|
|
@@ -113,12 +127,12 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
|
|
|
113
127
|
- DP-1 `evm` names a chain family rather than a network, so those items are skipped unless `--assume-ethereum` asserts which one they are. Getting that wrong attaches another artwork's metadata
|
|
114
128
|
- Options: `-o, --output <file>`, `--force` (replace existing manifests), `--assume-ethereum`, `-v, --verbose`
|
|
115
129
|
- `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
|
|
116
|
-
- Options: `-s, --server <index>` (server index if multiple configured), `--replace` (replace the playlist already stored under this document id instead of creating a new one), `-k, --key <privateKey>` (signs the replace authorization; `--replace` only)
|
|
117
|
-
- `--key`
|
|
130
|
+
- Options: `-s, --server <index>` (server index if multiple configured), `--replace` (replace the playlist already stored under this document id instead of creating a new one), `-k, --key <privateKey>` (signs the replace authorization; `--replace` only), `--key-file <path>` (the same key, read from a file)
|
|
131
|
+
- `--key` and `--key-file` are both refused on a plain `publish`, which signs nothing at request time — it uploads the `signatures[]` envelope the document already carries. Sign the file with `ff-cli sign --key-file` instead
|
|
118
132
|
- `fetch <id-or-url>` – Save a published playlist from a feed server. Accepts a playlist id, a slug, or a feed URL. The document goes to `-o` when given and to stdout otherwise (status lines always go to stderr, so `ff-cli fetch <id> > playlist.json` works). This is the starting point for `publish --replace`, which requires the stored `id`, `slug`, and `created`
|
|
119
133
|
- Options: `-s, --server <index>`, `-o, --output <file>`. There is no `--key`: a fetch is a plain read and signs nothing
|
|
120
134
|
- `unpublish <id-or-url>` – Delete a playlist from a feed server. Accepts a playlist id, a slug, or a feed URL. Requires the signing key to be an owner of the stored playlist, and confirms before deleting
|
|
121
|
-
- Options: `-s, --server <index>`, `-y, --yes` (skip the confirmation), `-k, --key <privateKey>` (signs the delete authorization)
|
|
135
|
+
- Options: `-s, --server <index>`, `-y, --yes` (skip the confirmation), `-k, --key <privateKey>` (signs the delete authorization), `--key-file <path>` (the same key, read from a file)
|
|
122
136
|
- `ssh <enable|disable>` – Manage SSH access on an FF1 device
|
|
123
137
|
- Options: `-d, --device <name>`, `--pubkey <path>`, `--ttl <duration>`
|
|
124
138
|
- `device list` – List all configured FF1 devices
|
|
@@ -319,6 +333,13 @@ If you sign with `sign --key <privateKey>`, read that key's identity with
|
|
|
319
333
|
and `build` need none of this: they declare the key and sign it as `curator` for you when a signing key
|
|
320
334
|
is configured. An `apiKey` left over in an existing config is ignored.
|
|
321
335
|
|
|
336
|
+
`--key-file <path>` is the same key from a file, on `sign`, `status`, `unpublish`, and
|
|
337
|
+
`publish --replace` alike — so `ff-cli status --key-file signing.key` reports the identity
|
|
338
|
+
`ff-cli sign --key-file signing.key` will assert. Prefer it: `--key` writes your private key into your
|
|
339
|
+
shell history and exposes it in the process list for the length of the run, and a signing key that has
|
|
340
|
+
been disclosed cannot be rotated out of a playlist that already names it. See
|
|
341
|
+
[Where the signing key can come from](./CONFIGURATION.md#where-the-signing-key-can-come-from).
|
|
342
|
+
|
|
322
343
|
```json
|
|
323
344
|
{
|
|
324
345
|
"feedServers": [
|
|
@@ -336,7 +357,10 @@ is configured. An `apiKey` left over in an existing config is ignored.
|
|
|
336
357
|
|
|
337
358
|
A feed's `PUT` and `DELETE` are owner-bound. Neither takes an API key, and neither is authorized by the
|
|
338
359
|
document alone: both carry a short-lived **intent** that ff-cli signs in the `curator` role — with the
|
|
339
|
-
configured `playlist.privateKey` by default, or with whatever `-k, --key`
|
|
360
|
+
configured `playlist.privateKey` by default, or with whatever `-k, --key` or `--key-file <path>` names
|
|
361
|
+
for that one command (see
|
|
362
|
+
[Where the signing key can come from](./CONFIGURATION.md#where-the-signing-key-can-come-from), which
|
|
363
|
+
explains why the file is the safer of the two). Only a key the *stored* playlist names in `curators[]` can authorize either one, which is
|
|
340
364
|
why `publish` refuses to create a playlist that carries no owner-role signature — such a document can be
|
|
341
365
|
neither replaced nor deleted, ever.
|
|
342
366
|
|
|
@@ -363,10 +387,12 @@ you published — re-running `find` or `build` mints a fresh id, slug, and `crea
|
|
|
363
387
|
playlist rather than a replacement. A `publish` without `--replace` is never silently upgraded to a
|
|
364
388
|
replace; an id the feed already holds fails with a conflict, as before.
|
|
365
389
|
|
|
366
|
-
The configured `playlist.privateKey` is only the default. `-k, --key`
|
|
367
|
-
for the intent signature and for the local ownership check alike — the way
|
|
368
|
-
`ff-cli
|
|
369
|
-
|
|
390
|
+
The configured `playlist.privateKey` is only the default. `-k, --key` and `--key-file <path>` override
|
|
391
|
+
it on both commands — for the intent signature and for the local ownership check alike — the way
|
|
392
|
+
`ff-cli sign` and `ff-cli status` already accept them. Passing both is an error rather than a precedence
|
|
393
|
+
rule, since a precedence rule silently ignores one of the two keys you supplied. A `--key` that is
|
|
394
|
+
present but empty (what `--key "$SIGNING_KEY"` becomes when the variable is unset), and a `--key-file`
|
|
395
|
+
that is missing, unreadable, or holds nothing, are all **rejected**, never
|
|
370
396
|
treated as absent: falling back to the configured key would authorize the write under an identity you did
|
|
371
397
|
not choose, and a delete cannot be taken back. `unpublish` derives the identity before it looks the
|
|
372
398
|
playlist up, so a bad credential fails before you are asked to confirm anything, and it prints
|