@redaktyn/cli 1.1.1 → 1.2.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/README.md +52 -0
- package/package.json +9 -4
- package/redaktyn-connect.mjs +91 -0
- package/redaktyn-import.mjs +65 -10
- package/redaktyn-invite.mjs +116 -0
- package/redaktyn-scan.mjs +24 -4
- package/redaktyn-session.mjs +201 -0
package/README.md
CHANGED
|
@@ -193,6 +193,58 @@ After generating the JSON:
|
|
|
193
193
|
|
|
194
194
|
---
|
|
195
195
|
|
|
196
|
+
## `redaktyn-connect` and `redaktyn-invite` — a developer who never holds admin credentials
|
|
197
|
+
|
|
198
|
+
`redaktyn-import` and `redaktyn-scan` normally need an OWNER/ADMIN login token and the workspace
|
|
199
|
+
passphrase. That is the right thing for an admin, and the wrong thing to hand a developer who only has to
|
|
200
|
+
enrol one engagement's secrets. An invite gives them the narrow version instead.
|
|
201
|
+
|
|
202
|
+
**Admin: mint a single-use link for one project.**
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
export REDAKTYN_SERVER_URL=https://redaktyn-api.onrender.com
|
|
206
|
+
export REDAKTYN_TOKEN=... # OWNER/ADMIN login token
|
|
207
|
+
export REDAKTYN_PASSPHRASE='...' # stays on this machine
|
|
208
|
+
|
|
209
|
+
redaktyn-invite --project acme-engagement --can-enrol --expires-days 3
|
|
210
|
+
# -> https://redaktyn.com/connect?code=ABCD1234#w=...
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The project's scanning key is derived locally and sealed under a one-time key that exists only in the link's
|
|
214
|
+
`#fragment`. The server stores ciphertext it cannot open, and the fragment is never sent to it. `--can-enrol`
|
|
215
|
+
is off unless you ask: without it the link is scan-only. A link carries a key for **one** device, so it works
|
|
216
|
+
once.
|
|
217
|
+
|
|
218
|
+
**Developer: connect once, then work.**
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
redaktyn-connect # prompts for the link, so it stays out of shell history
|
|
222
|
+
redaktyn-import --env .env # dry run, no environment variables needed
|
|
223
|
+
redaktyn-import --env .env --apply
|
|
224
|
+
redaktyn-scan --staged # also uses the saved key
|
|
225
|
+
redaktyn-connect --status | --logout
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
What the developer has, and does not have:
|
|
229
|
+
|
|
230
|
+
| | |
|
|
231
|
+
|---|---|
|
|
232
|
+
| Can | scan, and (with `--can-enrol`) register and re-fingerprint secrets **on that one project** |
|
|
233
|
+
| Cannot | enrol anywhere else, delete or edit a secret, see another project's digests, or reach any admin route |
|
|
234
|
+
| Holds | that project's key only (`HKDF(workspace key, project id)`), never the workspace key or passphrase |
|
|
235
|
+
| Revoked by | an admin removing the device (`DELETE /api/users/:id`): it stops working at once, not when a token expires |
|
|
236
|
+
|
|
237
|
+
The session lives in `~/.config/redaktyn/session.json` (directory `0700`, file `0600`), the same protection
|
|
238
|
+
`~/.aws/credentials` relies on. It is a file and not the OS keychain on purpose: macOS `security
|
|
239
|
+
add-generic-password` takes the secret as a command-line argument, which any process on the machine can read
|
|
240
|
+
with `ps` while it runs. The token renews itself; if the session cannot be renewed, or an admin removed the
|
|
241
|
+
device, the command says to ask for a new link. An admin's own `REDAKTYN_TOKEN` in the environment always takes
|
|
242
|
+
precedence over a saved session.
|
|
243
|
+
|
|
244
|
+
**Server note:** `--can-enrol` needs the `InviteEnrolGrant` table. Schema changes here are applied by hand
|
|
245
|
+
(`npx prisma db push`), and an invite minted before that is refused with `ENROL_GRANTS_UNAVAILABLE` rather
|
|
246
|
+
than quietly becoming a plain link.
|
|
247
|
+
|
|
196
248
|
## `redaktyn-import` — bulk enrollment from files and vaults
|
|
197
249
|
|
|
198
250
|
Enrollment used to mean typing every secret into the dashboard by hand, so coverage was
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redaktyn/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Redaktyn CLI: bulk-enrol secrets from a .env or any vault (--project aware), local fingerprints, git/directory digest scans. Digests are computed on your machine; secret values never leave it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"redaktyn-fingerprint": "./redaktyn-fingerprint.mjs",
|
|
8
8
|
"redaktyn-import": "./redaktyn-import.mjs",
|
|
9
9
|
"redaktyn-scan": "./redaktyn-scan.mjs",
|
|
10
|
-
"redaktyn-verify": "./redaktyn-verify.mjs"
|
|
10
|
+
"redaktyn-verify": "./redaktyn-verify.mjs",
|
|
11
|
+
"redaktyn-connect": "./redaktyn-connect.mjs",
|
|
12
|
+
"redaktyn-invite": "./redaktyn-invite.mjs"
|
|
11
13
|
},
|
|
12
14
|
"dependencies": {
|
|
13
15
|
"@redaktyn/shared": "^1.0.0"
|
|
@@ -37,10 +39,13 @@
|
|
|
37
39
|
"access": "public"
|
|
38
40
|
},
|
|
39
41
|
"files": [
|
|
42
|
+
"README.md",
|
|
43
|
+
"redaktyn-connect.mjs",
|
|
40
44
|
"redaktyn-fingerprint.mjs",
|
|
41
45
|
"redaktyn-import.mjs",
|
|
46
|
+
"redaktyn-invite.mjs",
|
|
42
47
|
"redaktyn-scan.mjs",
|
|
43
|
-
"redaktyn-
|
|
44
|
-
"
|
|
48
|
+
"redaktyn-session.mjs",
|
|
49
|
+
"redaktyn-verify.mjs"
|
|
45
50
|
]
|
|
46
51
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* redaktyn-connect: set this machine up from an invite link, so enrolling secrets
|
|
4
|
+
* never needs an admin login or the workspace passphrase.
|
|
5
|
+
*
|
|
6
|
+
* redaktyn-connect prompts for the link (kept out of shell history)
|
|
7
|
+
* redaktyn-connect --stdin reads the link from standard input
|
|
8
|
+
* redaktyn-connect '<link>' quote it: the part after # is the key
|
|
9
|
+
* redaktyn-connect --status what this machine is connected to
|
|
10
|
+
* redaktyn-connect --logout forget the session on this machine
|
|
11
|
+
*
|
|
12
|
+
* After connecting, `redaktyn-import --env .env` needs no environment variables.
|
|
13
|
+
* Ask your admin for the link; they create it with `redaktyn-invite`.
|
|
14
|
+
*/
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { realpathSync } from "node:fs";
|
|
17
|
+
import * as readline from "node:readline";
|
|
18
|
+
|
|
19
|
+
import { SessionError, connectWithInvite, deleteSession, loadSession, sessionDir } from "./redaktyn-session.mjs";
|
|
20
|
+
|
|
21
|
+
function help() {
|
|
22
|
+
console.log(`redaktyn-connect: set this machine up from an invite link
|
|
23
|
+
|
|
24
|
+
redaktyn-connect prompt for the link (not saved in shell history)
|
|
25
|
+
redaktyn-connect --stdin read the link from standard input
|
|
26
|
+
redaktyn-connect '<link>' pass the link; quote it, the key is after the #
|
|
27
|
+
redaktyn-connect --status show what this machine is connected to
|
|
28
|
+
redaktyn-connect --logout forget the session on this machine
|
|
29
|
+
|
|
30
|
+
--server <url> Override the server (default: the link's own address)
|
|
31
|
+
|
|
32
|
+
The session is stored in ${sessionDir()} (readable only by you).`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ask(question) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
38
|
+
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function readStdin() {
|
|
42
|
+
let s = "";
|
|
43
|
+
for await (const c of process.stdin) s += c;
|
|
44
|
+
return s.trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function show(s) {
|
|
48
|
+
console.log(`Connected to ${s.orgName ?? s.orgId}${s.projectName ? `, project ${s.projectName}` : ""}.`);
|
|
49
|
+
console.log(` server ${s.serverUrl}`);
|
|
50
|
+
console.log(` can enrol ${s.canEnrol ? "yes, on this project only" : "no (scan only). Ask for an invite that allows enrolling."}`);
|
|
51
|
+
console.log(` key ${s.keyFingerprint} (a fingerprint, never the key)`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function main(argv) {
|
|
55
|
+
const args = argv.slice(2);
|
|
56
|
+
if (args.includes("--help") || args.includes("-h")) return help(), 0;
|
|
57
|
+
if (args.includes("--logout")) {
|
|
58
|
+
const had = !!loadSession();
|
|
59
|
+
deleteSession();
|
|
60
|
+
console.log(had ? "Forgot this machine's Redaktyn session." : "There was no session to forget.");
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
if (args.includes("--status")) {
|
|
64
|
+
const s = loadSession();
|
|
65
|
+
if (!s) { console.log("Not connected. Run: redaktyn-connect"); return 1; }
|
|
66
|
+
show(s);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
const si = args.indexOf("--server");
|
|
70
|
+
const server = si >= 0 ? args[si + 1] : undefined;
|
|
71
|
+
let link = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--server");
|
|
72
|
+
if (args.includes("--stdin")) link = await readStdin();
|
|
73
|
+
if (!link) {
|
|
74
|
+
if (!process.stdin.isTTY) { console.error("Pass the link, use --stdin, or run this in a terminal."); return 2; }
|
|
75
|
+
link = await ask("Invite link: ");
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const s = await connectWithInvite(link, { serverUrl: server });
|
|
79
|
+
show(s);
|
|
80
|
+
console.log("\nNext: redaktyn-import --env .env (add --apply to register)");
|
|
81
|
+
return 0;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (e instanceof SessionError) { console.error(e.message); return 1; }
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function realArgv1() { try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; } }
|
|
89
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realArgv1()).href) {
|
|
90
|
+
main(process.argv).then((c) => process.exit(c), (e) => { console.error(e.message); process.exit(1); });
|
|
91
|
+
}
|
package/redaktyn-import.mjs
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
deriveProjectKey,
|
|
49
49
|
enrollPayload,
|
|
50
50
|
} from "./redaktyn-fingerprint.mjs";
|
|
51
|
+
import { freshSession, loadSession } from "./redaktyn-session.mjs";
|
|
51
52
|
|
|
52
53
|
/* ═══════════════════════════════════════════════════════════════
|
|
53
54
|
Colors
|
|
@@ -945,8 +946,16 @@ async function resolveProject(serverUrl, token, wanted) {
|
|
|
945
946
|
return matches[0];
|
|
946
947
|
}
|
|
947
948
|
|
|
948
|
-
async function fetchExistingSecrets(serverUrl, token) {
|
|
949
|
+
async function fetchExistingSecrets(serverUrl, token, viaSession = false) {
|
|
949
950
|
const r = await apiRequest(serverUrl, token, "GET", "/api/secrets");
|
|
951
|
+
if (viaSession && (r.status === 401 || r.status === 403)) {
|
|
952
|
+
// A connected developer is not holding an admin token, so telling them to set
|
|
953
|
+
// one would send them the wrong way. Their device was removed or ended.
|
|
954
|
+
throw new UsageError(
|
|
955
|
+
"This machine's Redaktyn session is no longer valid: the device was removed or its session ended. " +
|
|
956
|
+
"Ask your admin for a new invite link and run `redaktyn-connect` again."
|
|
957
|
+
);
|
|
958
|
+
}
|
|
950
959
|
if (r.status === 401) {
|
|
951
960
|
throw new UsageError(
|
|
952
961
|
"Server rejected the token (401). REDAKTYN_TOKEN must be a current OWNER or ADMIN login token."
|
|
@@ -1411,6 +1420,59 @@ async function resolveCredentials(args) {
|
|
|
1411
1420
|
return { serverUrl, token, orgId, passphrase };
|
|
1412
1421
|
}
|
|
1413
1422
|
|
|
1423
|
+
/**
|
|
1424
|
+
* Who is enrolling, and with which key.
|
|
1425
|
+
*
|
|
1426
|
+
* Two ways in. An admin sets REDAKTYN_TOKEN and the workspace passphrase and may
|
|
1427
|
+
* name any project with --project. A developer who ran `redaktyn-connect` has a
|
|
1428
|
+
* saved session instead: its key is already the project's key, so no passphrase or
|
|
1429
|
+
* admin token is involved, and it can only ever act on the project its invite named.
|
|
1430
|
+
* An admin's environment wins when both exist, so a shell set up for admin work is
|
|
1431
|
+
* never silently downgraded to a developer session.
|
|
1432
|
+
*/
|
|
1433
|
+
async function resolveIdentity(args) {
|
|
1434
|
+
const adminEnv = process.env["REDAKTYN_TOKEN"] || process.env["REDAKTYN_JWT"];
|
|
1435
|
+
const saved = adminEnv ? null : loadSession();
|
|
1436
|
+
if (saved) {
|
|
1437
|
+
let session;
|
|
1438
|
+
try {
|
|
1439
|
+
session = await freshSession(saved);
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
throw new UsageError(e.message);
|
|
1442
|
+
}
|
|
1443
|
+
if (args.project) {
|
|
1444
|
+
const w = args.project.trim().toLowerCase();
|
|
1445
|
+
if (w !== String(session.projectId).toLowerCase() && w !== String(session.projectName || "").toLowerCase()) {
|
|
1446
|
+
throw new UsageError(
|
|
1447
|
+
`This machine is connected to project "${session.projectName ?? session.projectId}" only, not "${args.project}". ` +
|
|
1448
|
+
"Drop --project, or ask for an invite to the other project."
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
if (!session.projectId) throw new UsageError("This session is not tied to a project. Ask for a new invite link.");
|
|
1453
|
+
if (args.apply && !session.canEnrol) {
|
|
1454
|
+
throw new UsageError(
|
|
1455
|
+
"This invite lets you scan but not enrol secrets. Ask your admin for a link made with: redaktyn-invite --can-enrol"
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
return {
|
|
1459
|
+
serverUrl: args.server || session.serverUrl,
|
|
1460
|
+
token: session.token,
|
|
1461
|
+
key: Buffer.from(session.key, "hex"),
|
|
1462
|
+
project: { id: session.projectId, name: session.projectName ?? session.projectId, usesOrgKey: session.projectUsesOrgKey === true },
|
|
1463
|
+
viaSession: true,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
const { serverUrl, token, orgId, passphrase } = await resolveCredentials(args);
|
|
1467
|
+
const orgKey = await deriveKey(passphrase, orgId);
|
|
1468
|
+
const project = args.project ? await resolveProject(serverUrl, token, args.project) : null;
|
|
1469
|
+
// A project with its own key hashes under HKDF(orgKey, projectId); the default
|
|
1470
|
+
// project carries the org key itself. Filing a digest under the wrong one lists
|
|
1471
|
+
// as ACTIVE and matches nothing, which is why the server also checks.
|
|
1472
|
+
const key = project && !project.usesOrgKey ? deriveProjectKey(orgKey, project.id) : orgKey;
|
|
1473
|
+
return { serverUrl, token, key, project, viaSession: false };
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1414
1476
|
async function main() {
|
|
1415
1477
|
const args = parseArgs(process.argv);
|
|
1416
1478
|
if (args.help) {
|
|
@@ -1437,15 +1499,8 @@ async function main() {
|
|
|
1437
1499
|
);
|
|
1438
1500
|
}
|
|
1439
1501
|
|
|
1440
|
-
const { serverUrl, token,
|
|
1441
|
-
|
|
1502
|
+
const { serverUrl, token, key, project, viaSession } = await resolveIdentity(args);
|
|
1442
1503
|
const minLength = args.minLength ?? DEFAULT_MIN_LENGTH;
|
|
1443
|
-
const orgKey = await deriveKey(passphrase, orgId);
|
|
1444
|
-
const project = args.project ? await resolveProject(serverUrl, token, args.project) : null;
|
|
1445
|
-
// A project with its own key hashes under HKDF(orgKey, projectId); the default
|
|
1446
|
-
// project carries the org key itself. Filing a digest under the wrong one lists
|
|
1447
|
-
// as ACTIVE and matches nothing, which is why the server also checks.
|
|
1448
|
-
const key = project && !project.usesOrgKey ? deriveProjectKey(orgKey, project.id) : orgKey;
|
|
1449
1504
|
|
|
1450
1505
|
const candidates = [];
|
|
1451
1506
|
const rejected = [];
|
|
@@ -1471,7 +1526,7 @@ async function main() {
|
|
|
1471
1526
|
});
|
|
1472
1527
|
}
|
|
1473
1528
|
|
|
1474
|
-
let existing = await fetchExistingSecrets(serverUrl, token);
|
|
1529
|
+
let existing = await fetchExistingSecrets(serverUrl, token, viaSession);
|
|
1475
1530
|
// Compared only with the chosen project's own secrets: the same label in
|
|
1476
1531
|
// another project is a different secret under a different key.
|
|
1477
1532
|
if (project) existing = existing.filter((s) => s && s.projectId === project.id);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* redaktyn-invite: for an admin. Mint a single-use invite link for one project,
|
|
4
|
+
* optionally allowing the person who redeems it from the CLI to enrol secrets.
|
|
5
|
+
*
|
|
6
|
+
* redaktyn-invite --project acme-engagement --can-enrol
|
|
7
|
+
*
|
|
8
|
+
* Needs what any admin action needs: REDAKTYN_SERVER_URL, REDAKTYN_TOKEN (OWNER or
|
|
9
|
+
* ADMIN login token) and REDAKTYN_PASSPHRASE. The passphrase stays on this machine:
|
|
10
|
+
* it derives the project's key, which is sealed under a one-time key that exists
|
|
11
|
+
* only in the link's #fragment. The server stores ciphertext it cannot open.
|
|
12
|
+
*
|
|
13
|
+
* Send the link over a channel you trust. Whoever opens it first (once) gets the
|
|
14
|
+
* project's key and, with --can-enrol, the right to register secrets on it.
|
|
15
|
+
*/
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import { realpathSync } from "node:fs";
|
|
18
|
+
import * as readline from "node:readline";
|
|
19
|
+
|
|
20
|
+
import { deriveKey, deriveProjectKey } from "./redaktyn-fingerprint.mjs";
|
|
21
|
+
import { SessionError, apiJson, sealInviteKey } from "./redaktyn-session.mjs";
|
|
22
|
+
|
|
23
|
+
function help() {
|
|
24
|
+
console.log(`redaktyn-invite: mint a single-use invite link for one project
|
|
25
|
+
|
|
26
|
+
--project <name|id> The project the link is for (required)
|
|
27
|
+
--can-enrol Let the redeeming CLI register secrets on that project
|
|
28
|
+
--expires-days <n> Link lifetime (default 7)
|
|
29
|
+
--name <text> Name to show for the person
|
|
30
|
+
--email <address> Restrict the link to this email
|
|
31
|
+
--app-url <url> Address to put in the link (default https://redaktyn.com)
|
|
32
|
+
--server <url> API address (default REDAKTYN_SERVER_URL)
|
|
33
|
+
|
|
34
|
+
Environment: REDAKTYN_SERVER_URL, REDAKTYN_TOKEN (OWNER/ADMIN), REDAKTYN_PASSPHRASE`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parse(argv) {
|
|
38
|
+
const o = { project: null, canEnrol: false, days: 7, name: null, email: null, appUrl: "https://redaktyn.com", server: null, help: false };
|
|
39
|
+
for (let i = 2; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
const need = () => { const v = argv[++i]; if (v === undefined || v.startsWith("--")) throw new SessionError(`${a} needs a value`); return v; };
|
|
42
|
+
if (a === "--help" || a === "-h") o.help = true;
|
|
43
|
+
else if (a === "--project") o.project = need();
|
|
44
|
+
else if (a === "--can-enrol" || a === "--can-enroll") o.canEnrol = true;
|
|
45
|
+
else if (a === "--expires-days") o.days = Number(need());
|
|
46
|
+
else if (a === "--name") o.name = need();
|
|
47
|
+
else if (a === "--email") o.email = need();
|
|
48
|
+
else if (a === "--app-url") o.appUrl = need();
|
|
49
|
+
else if (a === "--server") o.server = need();
|
|
50
|
+
else throw new SessionError(`Unknown flag: ${a}`);
|
|
51
|
+
}
|
|
52
|
+
if (!Number.isInteger(o.days) || o.days < 1 || o.days > 365) throw new SessionError("--expires-days must be 1 to 365");
|
|
53
|
+
return o;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function promptHidden(q) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
59
|
+
process.stderr.write(q + " ");
|
|
60
|
+
rl._writeToOutput = () => {};
|
|
61
|
+
rl.question("", (a) => { process.stderr.write("\n"); rl.close(); resolve(a); });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function orgIdFrom(token) {
|
|
65
|
+
try { return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()).orgId; } catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function main(argv) {
|
|
69
|
+
const o = parse(argv);
|
|
70
|
+
if (o.help) return help(), 0;
|
|
71
|
+
if (!o.project) throw new SessionError("Say which project the link is for: --project <name|id>");
|
|
72
|
+
const server = o.server || process.env["REDAKTYN_SERVER_URL"];
|
|
73
|
+
const token = process.env["REDAKTYN_TOKEN"];
|
|
74
|
+
if (!server || !token) throw new SessionError("Set REDAKTYN_SERVER_URL and REDAKTYN_TOKEN (an OWNER or ADMIN login token).");
|
|
75
|
+
const orgId = orgIdFrom(token);
|
|
76
|
+
if (!orgId) throw new SessionError("That token carries no org id.");
|
|
77
|
+
let passphrase = process.env["REDAKTYN_PASSPHRASE"];
|
|
78
|
+
if (!passphrase) {
|
|
79
|
+
if (!process.stdin.isTTY) throw new SessionError("Set REDAKTYN_PASSPHRASE (used locally to derive the key, never sent).");
|
|
80
|
+
passphrase = await promptHidden("Workspace passphrase:");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const pr = await apiJson(server, "GET", "/api/projects", { token });
|
|
84
|
+
if (!pr.ok || !Array.isArray(pr.data)) throw new SessionError(`GET /api/projects failed (${pr.status}). Is the token an OWNER/ADMIN token?`);
|
|
85
|
+
const want = o.project.trim();
|
|
86
|
+
const found = pr.data.filter((p) => p.id === want).length ? pr.data.filter((p) => p.id === want) : pr.data.filter((p) => String(p.name).toLowerCase() === want.toLowerCase());
|
|
87
|
+
if (found.length !== 1) throw new SessionError(found.length ? `"${want}" matches several projects; pass the id.` : `No project "${want}". Known: ${pr.data.map((p) => p.name).join(", ")}.`);
|
|
88
|
+
const project = found[0];
|
|
89
|
+
if (project.status === "CLOSED") throw new SessionError(`Project ${project.name} is closed.`);
|
|
90
|
+
|
|
91
|
+
const orgKey = await deriveKey(passphrase, orgId);
|
|
92
|
+
const key = project.usesOrgKey ? orgKey : deriveProjectKey(orgKey, project.id);
|
|
93
|
+
const { w, blob } = await sealInviteKey(key, project.usesOrgKey ? 1 : 2);
|
|
94
|
+
|
|
95
|
+
const r = await apiJson(server, "POST", "/api/invites", {
|
|
96
|
+
token,
|
|
97
|
+
body: {
|
|
98
|
+
projectId: project.id, keyBlob: blob, maxUses: 1, expiresInDays: o.days,
|
|
99
|
+
...(o.canEnrol ? { canEnrol: true } : {}),
|
|
100
|
+
...(o.name ? { assignedName: o.name } : {}), ...(o.email ? { assignedEmail: o.email } : {}),
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
if (!r.ok) throw new SessionError(`The server refused the invite (${r.status}): ${r.data?.error || r.text.slice(0, 200)}`);
|
|
104
|
+
|
|
105
|
+
const link = `${o.appUrl.replace(/\/+$/, "")}/connect?code=${r.data.code}#w=${w}`;
|
|
106
|
+
console.log(`Invite for project ${project.name}${o.canEnrol ? " (may enrol secrets)" : " (scan only)"}, single use, expires in ${o.days} day(s):\n`);
|
|
107
|
+
console.log(link);
|
|
108
|
+
console.log(`\nSend it over a channel you trust: whoever opens it first gets this project's key.`);
|
|
109
|
+
console.log(`They run: redaktyn-connect and paste it. Revoke before use: DELETE /api/invites/${r.data.id}`);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function realArgv1() { try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; } }
|
|
114
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realArgv1()).href) {
|
|
115
|
+
main(process.argv).then((c) => process.exit(c), (e) => { console.error(e.message); process.exit(e instanceof SessionError ? 1 : 2); });
|
|
116
|
+
}
|
package/redaktyn-scan.mjs
CHANGED
|
@@ -299,18 +299,38 @@ async function main() {
|
|
|
299
299
|
process.exit(2);
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
// A machine set up with `redaktyn-connect` scans with its saved project key and
|
|
303
|
+
// token, no environment needed. Any REDAKTYN_* credential in the environment
|
|
304
|
+
// takes precedence, so an existing pipeline behaves exactly as before.
|
|
305
|
+
const envCreds =
|
|
306
|
+
process.env["REDAKTYN_DEVICE_TOKEN"] || process.env["REDAKTYN_TOKEN"] || process.env["REDAKTYN_JWT"] ||
|
|
307
|
+
process.env["REDAKTYN_PASSPHRASE"];
|
|
308
|
+
let saved = null;
|
|
309
|
+
if (!envCreds) {
|
|
310
|
+
const { loadSession, freshSession } = await import("./redaktyn-session.mjs");
|
|
311
|
+
if (loadSession()) {
|
|
312
|
+
try {
|
|
313
|
+
saved = await freshSession();
|
|
314
|
+
} catch (e) {
|
|
315
|
+
console.error(e.message);
|
|
316
|
+
process.exit(2);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
302
321
|
const serverUrl =
|
|
303
|
-
args.server || process.env["REDAKTYN_SERVER_URL"] || process.env["REDAKTYN_SERVER"];
|
|
322
|
+
args.server || saved?.serverUrl || process.env["REDAKTYN_SERVER_URL"] || process.env["REDAKTYN_SERVER"];
|
|
304
323
|
const token =
|
|
324
|
+
saved?.token ||
|
|
305
325
|
process.env["REDAKTYN_DEVICE_TOKEN"] ||
|
|
306
326
|
process.env["REDAKTYN_TOKEN"] ||
|
|
307
327
|
process.env["REDAKTYN_JWT"];
|
|
308
|
-
const passphrase = process.env["REDAKTYN_PASSPHRASE"];
|
|
328
|
+
const passphrase = saved ? "session" : process.env["REDAKTYN_PASSPHRASE"];
|
|
309
329
|
const envOrgId = args.orgId || process.env["REDAKTYN_ORG_ID"];
|
|
310
330
|
|
|
311
331
|
if (!serverUrl || !token || !passphrase) {
|
|
312
332
|
console.error(
|
|
313
|
-
"
|
|
333
|
+
"Not connected. Run `redaktyn-connect` with an invite link, or set REDAKTYN_SERVER_URL, REDAKTYN_PASSPHRASE and REDAKTYN_DEVICE_TOKEN (or REDAKTYN_TOKEN)."
|
|
314
334
|
);
|
|
315
335
|
process.exit(2);
|
|
316
336
|
}
|
|
@@ -377,7 +397,7 @@ async function main() {
|
|
|
377
397
|
process.exit(2);
|
|
378
398
|
}
|
|
379
399
|
|
|
380
|
-
const key = await deriveHmacKeyFromPassphrase(passphrase, orgId);
|
|
400
|
+
const key = saved ? new Uint8Array(Buffer.from(saved.key, "hex")) : await deriveHmacKeyFromPassphrase(passphrase, orgId);
|
|
381
401
|
const stats = { total: 0, passesRun: [], exhausted: false, truncatedChars: false };
|
|
382
402
|
const endpoint = `${serverUrl.replace(/\/+$/, "")}/api/cli/scan`;
|
|
383
403
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's own enrolment: redeeming an invite so a developer can enrol secrets
|
|
3
|
+
* without ever holding an admin login or the workspace passphrase.
|
|
4
|
+
*
|
|
5
|
+
* What an invite hands a device is exactly what the browser extension gets: the
|
|
6
|
+
* project's scanning key, sealed under a one-time key that lives only in the link's
|
|
7
|
+
* fragment, plus a short-lived device token. The server never sees the unsealed key.
|
|
8
|
+
* This module redeems the link, unseals the key locally, keeps the result in a file
|
|
9
|
+
* only the current user can read, and renews the token as it ages.
|
|
10
|
+
*
|
|
11
|
+
* Why a file and not the OS keychain: macOS `security add-generic-password` takes
|
|
12
|
+
* the secret as a command-line argument, which any process on the machine can read
|
|
13
|
+
* with `ps` for as long as it runs. A 0600 file in a 0700 directory is the same
|
|
14
|
+
* protection `~/.aws/credentials` and `~/.ssh` rely on, without that window.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomBytes, webcrypto } from "node:crypto";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, renameSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
export const TOKEN_REFRESH_AFTER_MS = 20 * 60 * 60 * 1000; // tokens last 24h
|
|
22
|
+
export const INVITE_KEY_BYTES = 32;
|
|
23
|
+
|
|
24
|
+
export class SessionError extends Error {}
|
|
25
|
+
|
|
26
|
+
/* ── where it lives ─────────────────────────────────────────── */
|
|
27
|
+
|
|
28
|
+
export function sessionDir() {
|
|
29
|
+
const base =
|
|
30
|
+
process.env["REDAKTYN_CONFIG_DIR"] ||
|
|
31
|
+
(process.env["XDG_CONFIG_HOME"] ? join(process.env["XDG_CONFIG_HOME"], "redaktyn") : join(homedir(), ".config", "redaktyn"));
|
|
32
|
+
return base;
|
|
33
|
+
}
|
|
34
|
+
const sessionFile = () => join(sessionDir(), "session.json");
|
|
35
|
+
const deviceFile = () => join(sessionDir(), "device-id");
|
|
36
|
+
|
|
37
|
+
function ensureDir() {
|
|
38
|
+
mkdirSync(sessionDir(), { recursive: true, mode: 0o700 });
|
|
39
|
+
try { chmodSync(sessionDir(), 0o700); } catch { /* best effort on exotic filesystems */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Written to a temp file and renamed, so a crash never leaves half a session. */
|
|
43
|
+
function writePrivate(path, text) {
|
|
44
|
+
ensureDir();
|
|
45
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
46
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
47
|
+
try { chmodSync(tmp, 0o600); } catch { /* ignore */ }
|
|
48
|
+
renameSync(tmp, path);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function loadSession() {
|
|
52
|
+
const f = sessionFile();
|
|
53
|
+
if (!existsSync(f)) return null;
|
|
54
|
+
try {
|
|
55
|
+
const s = JSON.parse(readFileSync(f, "utf8"));
|
|
56
|
+
if (s && s.token && s.key && s.serverUrl && s.orgId) return s;
|
|
57
|
+
} catch { /* fall through */ }
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
export function saveSession(session) {
|
|
61
|
+
writePrivate(sessionFile(), JSON.stringify(session, null, 2) + "\n");
|
|
62
|
+
}
|
|
63
|
+
export function deleteSession() {
|
|
64
|
+
const f = sessionFile();
|
|
65
|
+
if (existsSync(f)) rmSync(f);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Stable per machine, so reconnecting the same CLI does not spend another seat. */
|
|
70
|
+
export function deviceId() {
|
|
71
|
+
const f = deviceFile();
|
|
72
|
+
if (existsSync(f)) {
|
|
73
|
+
const v = readFileSync(f, "utf8").trim();
|
|
74
|
+
if (/^cli-[a-f0-9]{32}$/.test(v)) return v;
|
|
75
|
+
}
|
|
76
|
+
const id = `cli-${randomBytes(16).toString("hex")}`;
|
|
77
|
+
writePrivate(f, id + "\n");
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ── the invite link ────────────────────────────────────────── */
|
|
82
|
+
|
|
83
|
+
const b64uToBytes = (t) => Buffer.from(t.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
84
|
+
export const bytesToB64u = (b) => Buffer.from(b).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
85
|
+
|
|
86
|
+
/** `https://host/connect?code=ABC#w=KEY` -> { origin, code, w }. */
|
|
87
|
+
export function parseInviteLink(link) {
|
|
88
|
+
let url;
|
|
89
|
+
try {
|
|
90
|
+
url = new URL(String(link).trim());
|
|
91
|
+
} catch {
|
|
92
|
+
throw new SessionError("That is not an invite link. It looks like https://…/connect?code=…#w=…");
|
|
93
|
+
}
|
|
94
|
+
const code = url.searchParams.get("code");
|
|
95
|
+
const w = new URLSearchParams(url.hash.replace(/^#/, "")).get("w");
|
|
96
|
+
if (!code) throw new SessionError("The link has no invite code.");
|
|
97
|
+
if (!w) {
|
|
98
|
+
throw new SessionError(
|
|
99
|
+
"The link has no key part (the bit after #w=). Copy the whole link, including everything after the #, and quote it so the shell does not cut it."
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (b64uToBytes(w).length !== INVITE_KEY_BYTES) throw new SessionError("The key part of the link is the wrong length.");
|
|
103
|
+
return { origin: url.origin, code, w };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** AES-GCM, no AAD: the exact inverse of wrapScanKeyForInvite in the extension. */
|
|
107
|
+
export async function unsealInviteKey(blob, wB64u) {
|
|
108
|
+
if (!blob || typeof blob !== "object" || (blob.v !== 1 && blob.v !== 2) || typeof blob.iv !== "string" || typeof blob.ct !== "string") {
|
|
109
|
+
throw new SessionError("The server sent no sealed key for this invite.");
|
|
110
|
+
}
|
|
111
|
+
const key = await webcrypto.subtle.importKey("raw", b64uToBytes(wB64u), { name: "AES-GCM" }, false, ["decrypt"]);
|
|
112
|
+
try {
|
|
113
|
+
const plain = await webcrypto.subtle.decrypt({ name: "AES-GCM", iv: Buffer.from(blob.iv, "base64") }, key, Buffer.from(blob.ct, "base64"));
|
|
114
|
+
const out = Buffer.from(plain);
|
|
115
|
+
if (out.length !== INVITE_KEY_BYTES) throw new Error("length");
|
|
116
|
+
return out;
|
|
117
|
+
} catch {
|
|
118
|
+
throw new SessionError("Could not open the sealed key with this link. The link may be incomplete or altered.");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Seal a 32-byte key for a link: returns the blob to POST and the link's `w`. */
|
|
123
|
+
export async function sealInviteKey(keyBytes, scope) {
|
|
124
|
+
const w = randomBytes(INVITE_KEY_BYTES);
|
|
125
|
+
const iv = randomBytes(12);
|
|
126
|
+
const aes = await webcrypto.subtle.importKey("raw", w, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
127
|
+
const ct = Buffer.from(await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, aes, keyBytes));
|
|
128
|
+
return { w: bytesToB64u(w), blob: { v: scope, iv: iv.toString("base64"), ct: ct.toString("base64") } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ── talking to the server ──────────────────────────────────── */
|
|
132
|
+
|
|
133
|
+
const trim = (u) => u.replace(/\/+$/, "");
|
|
134
|
+
|
|
135
|
+
export async function apiJson(serverUrl, method, path, { token, body } = {}) {
|
|
136
|
+
let res;
|
|
137
|
+
try {
|
|
138
|
+
res = await fetch(`${trim(serverUrl)}${path}`, {
|
|
139
|
+
method,
|
|
140
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body ? { "Content-Type": "application/json" } : {}) },
|
|
141
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
142
|
+
});
|
|
143
|
+
} catch (e) {
|
|
144
|
+
throw new SessionError(`Cannot reach ${trim(serverUrl)}: ${e.message}`);
|
|
145
|
+
}
|
|
146
|
+
const text = await res.text();
|
|
147
|
+
let data = null;
|
|
148
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = null; }
|
|
149
|
+
return { status: res.status, ok: res.ok, data, text };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Redeem a link and keep the result. Returns the saved session. */
|
|
153
|
+
export async function connectWithInvite(link, { serverUrl } = {}) {
|
|
154
|
+
const { origin, code, w } = parseInviteLink(link);
|
|
155
|
+
const server = serverUrl || process.env["REDAKTYN_SERVER_URL"] || origin;
|
|
156
|
+
const r = await apiJson(server, "POST", "/api/auth/connect", {
|
|
157
|
+
body: { inviteCode: code, extensionId: deviceId(), client: "cli" },
|
|
158
|
+
});
|
|
159
|
+
if (!r.ok) {
|
|
160
|
+
const why = r.data?.error || r.text.slice(0, 200) || `HTTP ${r.status}`;
|
|
161
|
+
throw new SessionError(`The server refused the invite (${r.status}): ${why}`);
|
|
162
|
+
}
|
|
163
|
+
const d = r.data || {};
|
|
164
|
+
if (!d.token || !d.keyBlob) {
|
|
165
|
+
throw new SessionError("The server accepted the invite but sent no sealed key, so nothing could be set up. Ask for a new link.");
|
|
166
|
+
}
|
|
167
|
+
const key = await unsealInviteKey(d.keyBlob, w);
|
|
168
|
+
const session = {
|
|
169
|
+
serverUrl: trim(server),
|
|
170
|
+
orgId: d.orgId,
|
|
171
|
+
orgName: d.orgName ?? null,
|
|
172
|
+
projectId: d.projectId ?? null,
|
|
173
|
+
projectName: d.projectName ?? null,
|
|
174
|
+
projectUsesOrgKey: d.projectUsesOrgKey ?? null,
|
|
175
|
+
canEnrol: d.canEnrol === true,
|
|
176
|
+
token: d.token,
|
|
177
|
+
tokenIssuedAt: Date.now(),
|
|
178
|
+
key: key.toString("hex"),
|
|
179
|
+
keyFingerprint: createHash("sha256").update(key).digest("hex").slice(0, 8),
|
|
180
|
+
connectedAt: new Date().toISOString(),
|
|
181
|
+
};
|
|
182
|
+
saveSession(session);
|
|
183
|
+
return session;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Renew the token if it is getting old. A refusal means the device was removed. */
|
|
187
|
+
export async function freshSession(session = loadSession()) {
|
|
188
|
+
if (!session) throw new SessionError("Not connected. Run: redaktyn-connect '<invite link>'");
|
|
189
|
+
if (Date.now() - (session.tokenIssuedAt || 0) < TOKEN_REFRESH_AFTER_MS) return session;
|
|
190
|
+
const r = await apiJson(session.serverUrl, "POST", "/api/auth/refresh", { token: session.token });
|
|
191
|
+
if (!r.ok || !r.data?.token) {
|
|
192
|
+
const code = r.data?.code;
|
|
193
|
+
const hint = code === "ENROLLMENT_REVOKED" || code === "SESSION_REVOKED"
|
|
194
|
+
? "This device was removed by an admin."
|
|
195
|
+
: "The session could not be renewed (it may have passed its maximum age).";
|
|
196
|
+
throw new SessionError(`${hint} Ask for a new invite link and run redaktyn-connect again.`);
|
|
197
|
+
}
|
|
198
|
+
const next = { ...session, token: r.data.token, tokenIssuedAt: Date.now() };
|
|
199
|
+
saveSession(next);
|
|
200
|
+
return next;
|
|
201
|
+
}
|