@seekrit/cli 0.0.1 → 0.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 +131 -0
- package/dist/index.js +1072 -821
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,921 +1,1172 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join, parse } from "node:path";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { Writable } from "node:stream";
|
|
9
|
+
//#region ../../packages/crypto/src/encoding.ts
|
|
10
|
+
const CHUNK = 32768;
|
|
11
|
+
/** Base64url (no padding) — portable across browsers, Workers, and Node. */
|
|
8
12
|
function toBase64Url(bytes) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
13
|
+
let binary = "";
|
|
14
|
+
for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
15
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
14
16
|
}
|
|
15
17
|
function fromBase64Url(text) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const base64 = text.replaceAll("-", "+").replaceAll("_", "/");
|
|
19
|
+
const binary = atob(base64);
|
|
20
|
+
const bytes = new Uint8Array(binary.length);
|
|
21
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
22
|
+
return bytes;
|
|
21
23
|
}
|
|
22
24
|
function utf8Encode(text) {
|
|
23
|
-
|
|
25
|
+
return new TextEncoder().encode(text);
|
|
24
26
|
}
|
|
25
27
|
function utf8Decode(bytes) {
|
|
26
|
-
|
|
28
|
+
return new TextDecoder().decode(bytes);
|
|
27
29
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region ../../packages/crypto/src/errors.ts
|
|
30
32
|
var SeekritCryptoError = class extends Error {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
code;
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "SeekritCryptoError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
37
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* Split a versioned blob like `sc1.<b64>.<b64>` and verify the prefix.
|
|
42
|
+
* AES-GCM auth failure downstream surfaces as DECRYPT_FAILED — that is also
|
|
43
|
+
* the "wrong passphrase" signal for passphrase-encrypted blobs.
|
|
44
|
+
*/
|
|
38
45
|
function splitBlob(blob, prefix, segments) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return parts.slice(1);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ../../packages/crypto/src/aes.ts
|
|
53
|
-
var SECRET_PREFIX = "sc1";
|
|
54
|
-
var IV_LENGTH = 12;
|
|
46
|
+
const parts = blob.split(".");
|
|
47
|
+
if (parts[0] !== prefix) throw new SeekritCryptoError("UNSUPPORTED_VERSION", `expected a "${prefix}" blob, got "${parts[0] ?? ""}"`);
|
|
48
|
+
if (parts.length !== segments + 1 || parts.some((p) => p.length === 0)) throw new SeekritCryptoError("MALFORMED_BLOB", `malformed "${prefix}" blob`);
|
|
49
|
+
return parts.slice(1);
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region ../../packages/crypto/src/aes.ts
|
|
53
|
+
const SECRET_PREFIX = "sc1";
|
|
54
|
+
const IV_LENGTH = 12;
|
|
55
|
+
/** Generate a fresh 256-bit data encryption key for an environment. */
|
|
55
56
|
function generateDek() {
|
|
56
|
-
|
|
57
|
+
return crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
|
|
57
58
|
}
|
|
58
59
|
async function importDek(dek, usage) {
|
|
59
|
-
|
|
60
|
+
return crypto.subtle.importKey("raw", dek, { name: "AES-GCM" }, false, [usage]);
|
|
60
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Encrypt a secret value with the environment DEK.
|
|
64
|
+
*
|
|
65
|
+
* @param aad Authenticated context binding the ciphertext to its location
|
|
66
|
+
* (e.g. `environmentId/SECRET_NAME`) so blobs cannot be swapped between
|
|
67
|
+
* secrets or environments without detection.
|
|
68
|
+
*/
|
|
61
69
|
async function encryptSecret(dek, plaintext, aad) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
const key = await importDek(dek, "encrypt");
|
|
71
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
72
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
73
|
+
name: "AES-GCM",
|
|
74
|
+
iv,
|
|
75
|
+
additionalData: utf8Encode(aad)
|
|
76
|
+
}, key, utf8Encode(plaintext));
|
|
77
|
+
return `${SECRET_PREFIX}.${toBase64Url(iv)}.${toBase64Url(new Uint8Array(ciphertext))}`;
|
|
70
78
|
}
|
|
71
79
|
async function decryptSecret(dek, blob, aad) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return utf8Decode(new Uint8Array(plaintext));
|
|
85
|
-
} catch {
|
|
86
|
-
throw new SeekritCryptoError(
|
|
87
|
-
"DECRYPT_FAILED",
|
|
88
|
-
"secret decryption failed: wrong key, tampered data, or mismatched context"
|
|
89
|
-
);
|
|
90
|
-
}
|
|
80
|
+
const [ivB64, ctB64] = splitBlob(blob, SECRET_PREFIX, 2);
|
|
81
|
+
const key = await importDek(dek, "decrypt");
|
|
82
|
+
try {
|
|
83
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
84
|
+
name: "AES-GCM",
|
|
85
|
+
iv: fromBase64Url(ivB64),
|
|
86
|
+
additionalData: utf8Encode(aad)
|
|
87
|
+
}, key, fromBase64Url(ctB64));
|
|
88
|
+
return utf8Decode(new Uint8Array(plaintext));
|
|
89
|
+
} catch {
|
|
90
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "secret decryption failed: wrong key, tampered data, or mismatched context");
|
|
91
|
+
}
|
|
91
92
|
}
|
|
93
|
+
/** AAD binding a secret ciphertext to its environment + name. */
|
|
92
94
|
function secretAad(environmentId, secretName) {
|
|
93
|
-
|
|
95
|
+
return `${environmentId}/${secretName}`;
|
|
94
96
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region ../../packages/crypto/src/keys.ts
|
|
97
99
|
async function generateKeyPair() {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
privateKeyJwk: JSON.stringify(privateJwk)
|
|
108
|
-
};
|
|
100
|
+
const pair = await crypto.subtle.generateKey({
|
|
101
|
+
name: "ECDH",
|
|
102
|
+
namedCurve: "P-256"
|
|
103
|
+
}, true, ["deriveBits"]);
|
|
104
|
+
const [publicJwk, privateJwk] = await Promise.all([crypto.subtle.exportKey("jwk", pair.publicKey), crypto.subtle.exportKey("jwk", pair.privateKey)]);
|
|
105
|
+
return {
|
|
106
|
+
publicKeyJwk: JSON.stringify(publicJwk),
|
|
107
|
+
privateKeyJwk: JSON.stringify(privateJwk)
|
|
108
|
+
};
|
|
109
109
|
}
|
|
110
110
|
async function importPublicKey(publicKeyJwk) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
true,
|
|
116
|
-
[]
|
|
117
|
-
);
|
|
111
|
+
return crypto.subtle.importKey("jwk", JSON.parse(publicKeyJwk), {
|
|
112
|
+
name: "ECDH",
|
|
113
|
+
namedCurve: "P-256"
|
|
114
|
+
}, true, []);
|
|
118
115
|
}
|
|
119
116
|
async function importPrivateKey(privateKeyJwk) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
true,
|
|
125
|
-
["deriveBits"]
|
|
126
|
-
);
|
|
117
|
+
return crypto.subtle.importKey("jwk", JSON.parse(privateKeyJwk), {
|
|
118
|
+
name: "ECDH",
|
|
119
|
+
namedCurve: "P-256"
|
|
120
|
+
}, true, ["deriveBits"]);
|
|
127
121
|
}
|
|
128
122
|
async function exportPrivateKeyPkcs8(key) {
|
|
129
|
-
|
|
123
|
+
return new Uint8Array(await crypto.subtle.exportKey("pkcs8", key));
|
|
130
124
|
}
|
|
131
125
|
async function importPrivateKeyPkcs8(pkcs8) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
126
|
+
return crypto.subtle.importKey("pkcs8", pkcs8, {
|
|
127
|
+
name: "ECDH",
|
|
128
|
+
namedCurve: "P-256"
|
|
129
|
+
}, true, ["deriveBits"]);
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region ../../packages/crypto/src/passphrase.ts
|
|
133
|
+
/**
|
|
134
|
+
* User private keys are stored server-side encrypted under a key derived from
|
|
135
|
+
* the user's passphrase, so any browser or CLI session can fetch and unlock
|
|
136
|
+
* them without the server ever seeing the passphrase or plaintext key.
|
|
137
|
+
*
|
|
138
|
+
* KDF is PBKDF2-HMAC-SHA256 (WebCrypto-native everywhere). The blob embeds
|
|
139
|
+
* its own salt + iteration count for future agility; bumping ITERATIONS only
|
|
140
|
+
* affects newly written blobs. TODO: revisit Argon2id via WASM later.
|
|
141
|
+
*
|
|
142
|
+
* Blob format: `pk1.<iterations>.<salt>.<iv>.<ciphertext>`
|
|
143
|
+
*/
|
|
144
|
+
const PK_PREFIX = "pk1";
|
|
145
|
+
/** OWASP-recommended minimum for PBKDF2-HMAC-SHA256. */
|
|
146
|
+
const PBKDF2_ITERATIONS = 6e5;
|
|
144
147
|
async function deriveKek(passphrase, salt, iterations, usage) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
{ name: "AES-GCM", length: 256 },
|
|
156
|
-
false,
|
|
157
|
-
[usage]
|
|
158
|
-
);
|
|
148
|
+
const material = await crypto.subtle.importKey("raw", utf8Encode(passphrase), "PBKDF2", false, ["deriveKey"]);
|
|
149
|
+
return crypto.subtle.deriveKey({
|
|
150
|
+
name: "PBKDF2",
|
|
151
|
+
hash: "SHA-256",
|
|
152
|
+
salt,
|
|
153
|
+
iterations
|
|
154
|
+
}, material, {
|
|
155
|
+
name: "AES-GCM",
|
|
156
|
+
length: 256
|
|
157
|
+
}, false, [usage]);
|
|
159
158
|
}
|
|
160
159
|
async function encryptPrivateKey(passphrase, privateKeyJwk) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
].join(".");
|
|
160
|
+
const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
161
|
+
const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
162
|
+
const kek = await deriveKek(passphrase, salt, PBKDF2_ITERATIONS, "encrypt");
|
|
163
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
164
|
+
name: "AES-GCM",
|
|
165
|
+
iv
|
|
166
|
+
}, kek, utf8Encode(privateKeyJwk));
|
|
167
|
+
return [
|
|
168
|
+
PK_PREFIX,
|
|
169
|
+
String(PBKDF2_ITERATIONS),
|
|
170
|
+
toBase64Url(salt),
|
|
171
|
+
toBase64Url(iv),
|
|
172
|
+
toBase64Url(new Uint8Array(ciphertext))
|
|
173
|
+
].join(".");
|
|
176
174
|
}
|
|
175
|
+
/** Wrong passphrase surfaces as SeekritCryptoError with code DECRYPT_FAILED. */
|
|
177
176
|
async function decryptPrivateKey(passphrase, blob) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
177
|
+
const [iterStr, saltB64, ivB64, ctB64] = splitBlob(blob, PK_PREFIX, 4);
|
|
178
|
+
const iterations = Number.parseInt(iterStr, 10);
|
|
179
|
+
if (!Number.isFinite(iterations) || iterations < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "invalid PBKDF2 iteration count");
|
|
180
|
+
const kek = await deriveKek(passphrase, fromBase64Url(saltB64), iterations, "decrypt");
|
|
181
|
+
try {
|
|
182
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
183
|
+
name: "AES-GCM",
|
|
184
|
+
iv: fromBase64Url(ivB64)
|
|
185
|
+
}, kek, fromBase64Url(ctB64));
|
|
186
|
+
return utf8Decode(new Uint8Array(plaintext));
|
|
187
|
+
} catch {
|
|
188
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "wrong passphrase or corrupted key blob");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region ../../packages/crypto/src/token.ts
|
|
193
|
+
/**
|
|
194
|
+
* Service tokens (CI, docker builds, agent proxies, k8s, …) are self-contained
|
|
195
|
+
* principals: the token string itself carries the private key, so the server
|
|
196
|
+
* never holds it. The server stores only the SHA-256 hash of the full token
|
|
197
|
+
* (for authentication) and the public key (for wrapping DEK grants).
|
|
198
|
+
*
|
|
199
|
+
* Format: `skt_<token id>_<private key pkcs8, base64url>`
|
|
200
|
+
*/
|
|
201
|
+
const TOKEN_PREFIX = "skt";
|
|
202
|
+
const TOKEN_ID_LENGTH = 22;
|
|
203
|
+
const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
200
204
|
function randomTokenId() {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
async function hashToken(
|
|
212
|
-
|
|
213
|
-
|
|
205
|
+
let out = "";
|
|
206
|
+
while (out.length < TOKEN_ID_LENGTH) {
|
|
207
|
+
const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_ID_LENGTH - out.length));
|
|
208
|
+
for (const byte of bytes) {
|
|
209
|
+
if (byte < 248) out += ID_ALPHABET[byte % 62];
|
|
210
|
+
if (out.length === TOKEN_ID_LENGTH) break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return `${TOKEN_PREFIX}_${out}`;
|
|
214
|
+
}
|
|
215
|
+
async function hashToken(token) {
|
|
216
|
+
const digest = await crypto.subtle.digest("SHA-256", utf8Encode(token));
|
|
217
|
+
return toBase64Url(new Uint8Array(digest));
|
|
214
218
|
}
|
|
215
219
|
async function createServiceToken() {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
220
|
+
const { publicKeyJwk, privateKeyJwk } = await generateKeyPair();
|
|
221
|
+
const pkcs8 = await exportPrivateKeyPkcs8(await importPrivateKey(privateKeyJwk));
|
|
222
|
+
const tokenId = randomTokenId();
|
|
223
|
+
const token = `${tokenId}_${toBase64Url(pkcs8)}`;
|
|
224
|
+
return {
|
|
225
|
+
token,
|
|
226
|
+
tokenId,
|
|
227
|
+
tokenHash: await hashToken(token),
|
|
228
|
+
publicKeyJwk
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
async function parseServiceToken(token) {
|
|
232
|
+
const match = /^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(token);
|
|
233
|
+
if (!match) throw new SeekritCryptoError("MALFORMED_TOKEN", "not a valid seekrit service token");
|
|
234
|
+
const [, tokenId, keyB64] = match;
|
|
235
|
+
try {
|
|
236
|
+
return {
|
|
237
|
+
tokenId,
|
|
238
|
+
privateKey: await importPrivateKeyPkcs8(fromBase64Url(keyB64))
|
|
239
|
+
};
|
|
240
|
+
} catch {
|
|
241
|
+
throw new SeekritCryptoError("MALFORMED_TOKEN", "service token private key is corrupted");
|
|
242
|
+
}
|
|
235
243
|
}
|
|
236
244
|
function isServiceToken(value) {
|
|
237
|
-
|
|
245
|
+
return value.startsWith(`${TOKEN_PREFIX}_`);
|
|
238
246
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region ../../packages/crypto/src/wrap.ts
|
|
249
|
+
/**
|
|
250
|
+
* ECIES-style key wrapping: an ephemeral P-256 keypair performs ECDH against
|
|
251
|
+
* the recipient's public key; the shared secret is run through HKDF-SHA256 to
|
|
252
|
+
* derive a one-time AES-256-GCM wrapping key. Only the holder of the
|
|
253
|
+
* recipient private key can unwrap.
|
|
254
|
+
*
|
|
255
|
+
* Blob format: `wd1.<ephemeral pub (raw)>.<hkdf salt>.<iv>.<ciphertext>`
|
|
256
|
+
*/
|
|
257
|
+
const WRAP_PREFIX = "wd1";
|
|
258
|
+
const HKDF_INFO = "seekrit/wrap-dek/v1";
|
|
243
259
|
async function deriveWrappingKey(ownPrivateKey, peerPublicKey, salt, usage) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
260
|
+
const ecdh = {
|
|
261
|
+
name: "ECDH",
|
|
262
|
+
public: peerPublicKey
|
|
263
|
+
};
|
|
264
|
+
const sharedBits = await crypto.subtle.deriveBits(ecdh, ownPrivateKey, 256);
|
|
265
|
+
const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
|
|
266
|
+
return crypto.subtle.deriveKey({
|
|
267
|
+
name: "HKDF",
|
|
268
|
+
hash: "SHA-256",
|
|
269
|
+
salt,
|
|
270
|
+
info: utf8Encode(HKDF_INFO)
|
|
271
|
+
}, hkdfKey, {
|
|
272
|
+
name: "AES-GCM",
|
|
273
|
+
length: 256
|
|
274
|
+
}, false, [usage]);
|
|
259
275
|
}
|
|
276
|
+
/** Wrap an environment DEK to a principal's public key. */
|
|
260
277
|
async function wrapDek(dek, recipientPublicKeyJwk) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
toBase64Url(new Uint8Array(ciphertext))
|
|
282
|
-
].join(".");
|
|
278
|
+
const recipientKey = await importPublicKey(recipientPublicKeyJwk);
|
|
279
|
+
const ephemeral = await crypto.subtle.generateKey({
|
|
280
|
+
name: "ECDH",
|
|
281
|
+
namedCurve: "P-256"
|
|
282
|
+
}, true, ["deriveBits"]);
|
|
283
|
+
const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
284
|
+
const wrappingKey = await deriveWrappingKey(ephemeral.privateKey, recipientKey, salt, "encrypt");
|
|
285
|
+
const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
286
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
287
|
+
name: "AES-GCM",
|
|
288
|
+
iv
|
|
289
|
+
}, wrappingKey, dek);
|
|
290
|
+
const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
|
|
291
|
+
return [
|
|
292
|
+
WRAP_PREFIX,
|
|
293
|
+
toBase64Url(ephemeralRaw),
|
|
294
|
+
toBase64Url(salt),
|
|
295
|
+
toBase64Url(iv),
|
|
296
|
+
toBase64Url(new Uint8Array(ciphertext))
|
|
297
|
+
].join(".");
|
|
283
298
|
}
|
|
299
|
+
/** Unwrap an environment DEK with the principal's private ECDH key. */
|
|
284
300
|
async function unwrapDek(wrapped, privateKey) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
);
|
|
305
|
-
return new Uint8Array(dek);
|
|
306
|
-
} catch {
|
|
307
|
-
throw new SeekritCryptoError(
|
|
308
|
-
"DECRYPT_FAILED",
|
|
309
|
-
"DEK unwrap failed: wrong private key or tampered grant"
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// src/index.ts
|
|
315
|
-
import { Command } from "commander";
|
|
316
|
-
|
|
317
|
-
// src/config.ts
|
|
318
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
319
|
-
import { homedir } from "os";
|
|
320
|
-
import { dirname, join, parse } from "path";
|
|
321
|
-
var DEFAULT_API_URL = "http://localhost:8787";
|
|
322
|
-
var PROJECT_FILE = "seekrit.json";
|
|
301
|
+
const [ephB64, saltB64, ivB64, ctB64] = splitBlob(wrapped, WRAP_PREFIX, 4);
|
|
302
|
+
const wrappingKey = await deriveWrappingKey(privateKey, await crypto.subtle.importKey("raw", fromBase64Url(ephB64), {
|
|
303
|
+
name: "ECDH",
|
|
304
|
+
namedCurve: "P-256"
|
|
305
|
+
}, false, []), fromBase64Url(saltB64), "decrypt");
|
|
306
|
+
try {
|
|
307
|
+
const dek = await crypto.subtle.decrypt({
|
|
308
|
+
name: "AES-GCM",
|
|
309
|
+
iv: fromBase64Url(ivB64)
|
|
310
|
+
}, wrappingKey, fromBase64Url(ctB64));
|
|
311
|
+
return new Uint8Array(dek);
|
|
312
|
+
} catch {
|
|
313
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "DEK unwrap failed: wrong private key or tampered grant");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region package.json
|
|
318
|
+
var version = "0.2.0";
|
|
319
|
+
const PROJECT_FILE = "seekrit.json";
|
|
323
320
|
function globalConfigPath() {
|
|
324
|
-
|
|
321
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
325
322
|
}
|
|
326
323
|
function readGlobalConfig() {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
324
|
+
const path = globalConfigPath();
|
|
325
|
+
if (!existsSync(path)) return {};
|
|
326
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
330
327
|
}
|
|
331
328
|
function writeGlobalConfig(update) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
329
|
+
const path = globalConfigPath();
|
|
330
|
+
const merged = {
|
|
331
|
+
...readGlobalConfig(),
|
|
332
|
+
...update
|
|
333
|
+
};
|
|
334
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
335
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
|
|
337
336
|
}
|
|
337
|
+
/** Walk up from cwd looking for seekrit.json. */
|
|
338
338
|
function findProjectConfig(startDir = process.cwd()) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
dir = dirname(dir);
|
|
348
|
-
}
|
|
339
|
+
let dir = startDir;
|
|
340
|
+
const { root } = parse(dir);
|
|
341
|
+
while (true) {
|
|
342
|
+
const candidate = join(dir, PROJECT_FILE);
|
|
343
|
+
if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8"));
|
|
344
|
+
if (dir === root) return null;
|
|
345
|
+
dir = dirname(dir);
|
|
346
|
+
}
|
|
349
347
|
}
|
|
350
348
|
function writeProjectConfig(config, dir = process.cwd()) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
return path;
|
|
349
|
+
const path = join(dir, PROJECT_FILE);
|
|
350
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
351
|
+
return path;
|
|
355
352
|
}
|
|
356
|
-
|
|
357
|
-
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region ../../packages/api-client/src/index.ts
|
|
358
355
|
var SeekritApiError = class extends Error {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
356
|
+
status;
|
|
357
|
+
code;
|
|
358
|
+
constructor(status, code, message) {
|
|
359
|
+
super(message);
|
|
360
|
+
this.name = "SeekritApiError";
|
|
361
|
+
this.status = status;
|
|
362
|
+
this.code = code;
|
|
363
|
+
}
|
|
367
364
|
};
|
|
368
365
|
var SeekritClient = class {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
366
|
+
baseUrl;
|
|
367
|
+
auth;
|
|
368
|
+
fetchImpl;
|
|
369
|
+
constructor(options) {
|
|
370
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
371
|
+
this.auth = options.auth;
|
|
372
|
+
this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
|
|
373
|
+
}
|
|
374
|
+
async request(method, path, body) {
|
|
375
|
+
const headers = { accept: "application/json" };
|
|
376
|
+
if (this.auth.type === "bearer") headers.authorization = `Bearer ${this.auth.token}`;
|
|
377
|
+
else if (this.auth.type === "dynamic") {
|
|
378
|
+
const token = await this.auth.getToken();
|
|
379
|
+
if (!token) throw new SeekritApiError(401, "unauthorized", "session expired");
|
|
380
|
+
headers.authorization = `Bearer ${token}`;
|
|
381
|
+
} else headers["x-seekrit-dev-user"] = this.auth.email;
|
|
382
|
+
if (body !== void 0) headers["content-type"] = "application/json";
|
|
383
|
+
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
384
|
+
method,
|
|
385
|
+
headers,
|
|
386
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
387
|
+
});
|
|
388
|
+
if (!res.ok) {
|
|
389
|
+
const fallback = { error: {
|
|
390
|
+
code: "internal",
|
|
391
|
+
message: `HTTP ${res.status}`
|
|
392
|
+
} };
|
|
393
|
+
const payload = await res.json().catch(() => fallback);
|
|
394
|
+
throw new SeekritApiError(res.status, payload.error?.code ?? "internal", payload.error?.message ?? `HTTP ${res.status}`);
|
|
395
|
+
}
|
|
396
|
+
return await res.json();
|
|
397
|
+
}
|
|
398
|
+
me() {
|
|
399
|
+
return this.request("GET", "/v1/me");
|
|
400
|
+
}
|
|
401
|
+
getMyKeys() {
|
|
402
|
+
return this.request("GET", "/v1/me/keys");
|
|
403
|
+
}
|
|
404
|
+
setMyKeys(input) {
|
|
405
|
+
return this.request("PUT", "/v1/me/keys", input);
|
|
406
|
+
}
|
|
407
|
+
listOrgs() {
|
|
408
|
+
return this.request("GET", "/v1/orgs");
|
|
409
|
+
}
|
|
410
|
+
createOrg(input) {
|
|
411
|
+
return this.request("POST", "/v1/orgs", input);
|
|
412
|
+
}
|
|
413
|
+
getOrg(orgId) {
|
|
414
|
+
return this.request("GET", `/v1/orgs/${orgId}`);
|
|
415
|
+
}
|
|
416
|
+
listMembers(orgId) {
|
|
417
|
+
return this.request("GET", `/v1/orgs/${orgId}/members`);
|
|
418
|
+
}
|
|
419
|
+
listApps(orgId) {
|
|
420
|
+
return this.request("GET", `/v1/orgs/${orgId}/apps`);
|
|
421
|
+
}
|
|
422
|
+
createApp(orgId, input) {
|
|
423
|
+
return this.request("POST", `/v1/orgs/${orgId}/apps`, input);
|
|
424
|
+
}
|
|
425
|
+
getApp(orgId, appId) {
|
|
426
|
+
return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}`);
|
|
427
|
+
}
|
|
428
|
+
deleteApp(orgId, appId) {
|
|
429
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/apps/${appId}`);
|
|
430
|
+
}
|
|
431
|
+
listEnvs(orgId, appId) {
|
|
432
|
+
return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}/envs`);
|
|
433
|
+
}
|
|
434
|
+
createEnv(orgId, appId, input) {
|
|
435
|
+
return this.request("POST", `/v1/orgs/${orgId}/apps/${appId}/envs`, input);
|
|
436
|
+
}
|
|
437
|
+
getEnv(orgId, envId) {
|
|
438
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}`);
|
|
439
|
+
}
|
|
440
|
+
deleteEnv(orgId, envId) {
|
|
441
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
|
|
442
|
+
}
|
|
443
|
+
listGroups(orgId) {
|
|
444
|
+
return this.request("GET", `/v1/orgs/${orgId}/groups`);
|
|
445
|
+
}
|
|
446
|
+
createGroup(orgId, input) {
|
|
447
|
+
return this.request("POST", `/v1/orgs/${orgId}/groups`, input);
|
|
448
|
+
}
|
|
449
|
+
getGroup(orgId, groupId) {
|
|
450
|
+
return this.request("GET", `/v1/orgs/${orgId}/groups/${groupId}`);
|
|
451
|
+
}
|
|
452
|
+
deleteGroup(orgId, groupId) {
|
|
453
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/groups/${groupId}`);
|
|
454
|
+
}
|
|
455
|
+
listGroupEnvs(orgId, groupId) {
|
|
456
|
+
return this.request("GET", `/v1/orgs/${orgId}/groups/${groupId}/envs`);
|
|
457
|
+
}
|
|
458
|
+
createGroupEnv(orgId, groupId, input) {
|
|
459
|
+
return this.request("POST", `/v1/orgs/${orgId}/groups/${groupId}/envs`, input);
|
|
460
|
+
}
|
|
461
|
+
listEnvGroups(orgId, envId) {
|
|
462
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/groups`);
|
|
463
|
+
}
|
|
464
|
+
linkEnvGroup(orgId, envId, input) {
|
|
465
|
+
return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/groups`, input);
|
|
466
|
+
}
|
|
467
|
+
unlinkEnvGroup(orgId, envId, groupId) {
|
|
468
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/groups/${groupId}`);
|
|
469
|
+
}
|
|
470
|
+
/** Ordered ciphertext layers + wrapped DEKs for the calling principal. */
|
|
471
|
+
resolve(query = {}) {
|
|
472
|
+
const params = new URLSearchParams();
|
|
473
|
+
if (query.env) params.set("env", query.env);
|
|
474
|
+
for (const [group, slug] of Object.entries(query.with ?? {})) params.append("with", `${group}:${slug}`);
|
|
475
|
+
const qs = params.size > 0 ? `?${params}` : "";
|
|
476
|
+
return this.request("GET", `/v1/resolve${qs}`);
|
|
477
|
+
}
|
|
478
|
+
listSecrets(orgId, envId) {
|
|
479
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/secrets`);
|
|
480
|
+
}
|
|
481
|
+
setSecret(orgId, envId, name, ciphertext) {
|
|
482
|
+
return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, { ciphertext });
|
|
483
|
+
}
|
|
484
|
+
deleteSecret(orgId, envId, name) {
|
|
485
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`);
|
|
486
|
+
}
|
|
487
|
+
/** The calling principal's wrapped DEK for this environment. */
|
|
488
|
+
getMyEnvKey(orgId, envId) {
|
|
489
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/key`);
|
|
490
|
+
}
|
|
491
|
+
listEnvKeys(orgId, envId) {
|
|
492
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/keys`);
|
|
493
|
+
}
|
|
494
|
+
grantEnvKey(orgId, envId, input) {
|
|
495
|
+
return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/keys`, input);
|
|
496
|
+
}
|
|
497
|
+
revokeEnvKey(orgId, envId, grantId) {
|
|
498
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/keys/${grantId}`);
|
|
499
|
+
}
|
|
500
|
+
listTokens(orgId) {
|
|
501
|
+
return this.request("GET", `/v1/orgs/${orgId}/tokens`);
|
|
502
|
+
}
|
|
503
|
+
createToken(orgId, input) {
|
|
504
|
+
return this.request("POST", `/v1/orgs/${orgId}/tokens`, input);
|
|
505
|
+
}
|
|
506
|
+
revokeToken(orgId, tokenId) {
|
|
507
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}`);
|
|
508
|
+
}
|
|
509
|
+
listAudit(orgId, query = {}) {
|
|
510
|
+
const params = new URLSearchParams();
|
|
511
|
+
if (query.cursor) params.set("cursor", query.cursor);
|
|
512
|
+
if (query.limit) params.set("limit", String(query.limit));
|
|
513
|
+
if (query.action) params.set("action", query.action);
|
|
514
|
+
if (query.resourceType) params.set("resourceType", query.resourceType);
|
|
515
|
+
const qs = params.size > 0 ? `?${params}` : "";
|
|
516
|
+
return this.request("GET", `/v1/orgs/${orgId}/audit${qs}`);
|
|
517
|
+
}
|
|
498
518
|
};
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
import { createInterface } from "readline";
|
|
502
|
-
import { Writable } from "stream";
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region src/io.ts
|
|
503
521
|
function fail(message) {
|
|
504
|
-
|
|
505
|
-
|
|
522
|
+
console.error(`error: ${message}`);
|
|
523
|
+
process.exit(1);
|
|
506
524
|
}
|
|
525
|
+
/** Prompt without echoing input (for passphrases). */
|
|
507
526
|
function promptHidden(question) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
527
|
+
const muted = new Writable({ write(_chunk, _encoding, callback) {
|
|
528
|
+
callback();
|
|
529
|
+
} });
|
|
530
|
+
process.stderr.write(question);
|
|
531
|
+
const rl = createInterface({
|
|
532
|
+
input: process.stdin,
|
|
533
|
+
output: muted,
|
|
534
|
+
terminal: true
|
|
535
|
+
});
|
|
536
|
+
return new Promise((resolve) => {
|
|
537
|
+
rl.question("", (answer) => {
|
|
538
|
+
rl.close();
|
|
539
|
+
process.stderr.write("\n");
|
|
540
|
+
resolve(answer);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
522
543
|
}
|
|
544
|
+
/** Read all of stdin (for `seekrit secrets set NAME -` piping). */
|
|
523
545
|
async function readStdin() {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
546
|
+
const chunks = [];
|
|
547
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
548
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
549
|
+
}
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/context.ts
|
|
552
|
+
/**
|
|
553
|
+
* Build the client context from configured credentials, or return null when
|
|
554
|
+
* none are set. `seekrit run` uses this to degrade to a plain launcher instead
|
|
555
|
+
* of exiting; every other command goes through `buildContext`, which fails.
|
|
556
|
+
*/
|
|
557
|
+
function tryBuildContext() {
|
|
558
|
+
const config = readGlobalConfig();
|
|
559
|
+
const apiUrl = process.env.SEEKRIT_API_URL ?? config.apiUrl ?? "http://localhost:8787";
|
|
560
|
+
const token = process.env.SEEKRIT_TOKEN ?? config.token;
|
|
561
|
+
const devUser = process.env.SEEKRIT_DEV_USER ?? config.devUser;
|
|
562
|
+
let auth;
|
|
563
|
+
if (token) auth = {
|
|
564
|
+
type: "bearer",
|
|
565
|
+
token
|
|
566
|
+
};
|
|
567
|
+
else if (devUser) auth = {
|
|
568
|
+
type: "dev",
|
|
569
|
+
email: devUser
|
|
570
|
+
};
|
|
571
|
+
else return null;
|
|
572
|
+
return {
|
|
573
|
+
client: new SeekritClient({
|
|
574
|
+
baseUrl: apiUrl,
|
|
575
|
+
auth
|
|
576
|
+
}),
|
|
577
|
+
auth
|
|
578
|
+
};
|
|
527
579
|
}
|
|
528
|
-
|
|
529
|
-
// src/context.ts
|
|
530
580
|
function buildContext() {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const devUser = process.env.SEEKRIT_DEV_USER ?? config.devUser;
|
|
535
|
-
let auth;
|
|
536
|
-
if (token2) auth = { type: "bearer", token: token2 };
|
|
537
|
-
else if (devUser) auth = { type: "dev", email: devUser };
|
|
538
|
-
else {
|
|
539
|
-
fail(
|
|
540
|
-
"no credentials found \u2014 run `seekrit login --token skt_\u2026`, or set SEEKRIT_TOKEN / SEEKRIT_DEV_USER"
|
|
541
|
-
);
|
|
542
|
-
}
|
|
543
|
-
return { client: new SeekritClient({ baseUrl: apiUrl, auth }), auth };
|
|
544
|
-
}
|
|
545
|
-
function requireProject() {
|
|
546
|
-
const project = findProjectConfig();
|
|
547
|
-
if (!project) {
|
|
548
|
-
fail("no seekrit.json found \u2014 run `seekrit init` in your project directory");
|
|
549
|
-
}
|
|
550
|
-
return project;
|
|
581
|
+
const ctx = tryBuildContext();
|
|
582
|
+
if (!ctx) fail("no credentials found — run `seekrit login --token skt_…`, or set SEEKRIT_TOKEN / SEEKRIT_DEV_USER");
|
|
583
|
+
return ctx;
|
|
551
584
|
}
|
|
585
|
+
function isTokenAuth(ctx) {
|
|
586
|
+
return ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Recover the calling principal's private key:
|
|
590
|
+
* - service tokens carry their private key in the token string;
|
|
591
|
+
* - users fetch their passphrase-encrypted key from the API and unlock it.
|
|
592
|
+
*/
|
|
593
|
+
async function getPrivateKey(ctx) {
|
|
594
|
+
if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
|
|
595
|
+
const { privateKey } = await parseServiceToken(ctx.auth.token);
|
|
596
|
+
return privateKey;
|
|
597
|
+
}
|
|
598
|
+
const { encryptedPrivateKey } = await ctx.client.getMyKeys();
|
|
599
|
+
return importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey));
|
|
600
|
+
}
|
|
601
|
+
/** Recover one environment's DEK for the current principal. */
|
|
552
602
|
async function getDek(ctx, orgId, envId) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const { encryptedPrivateKey } = await ctx.client.getMyKeys();
|
|
559
|
-
const passphrase = process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: ");
|
|
560
|
-
const privateKeyJwk = await decryptPrivateKey(passphrase, encryptedPrivateKey);
|
|
561
|
-
return unwrapDek(wrappedDek, await importPrivateKey(privateKeyJwk));
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// src/format.ts
|
|
603
|
+
const [{ wrappedDek }, privateKey] = await Promise.all([ctx.client.getMyEnvKey(orgId, envId), getPrivateKey(ctx)]);
|
|
604
|
+
return unwrapDek(wrappedDek, privateKey);
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/format.ts
|
|
565
608
|
function needsQuoting(value) {
|
|
566
|
-
|
|
609
|
+
return /[\s"'`$\\#]/.test(value) || value === "";
|
|
567
610
|
}
|
|
568
611
|
function dotenvQuote(value) {
|
|
569
|
-
|
|
570
|
-
|
|
612
|
+
if (!needsQuoting(value)) return value;
|
|
613
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
|
|
571
614
|
}
|
|
572
615
|
function shellQuote(value) {
|
|
573
|
-
|
|
616
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
574
617
|
}
|
|
575
618
|
function formatSecrets(values, format) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
619
|
+
const names = Object.keys(values).sort();
|
|
620
|
+
switch (format) {
|
|
621
|
+
case "json": return JSON.stringify(values, names, 2);
|
|
622
|
+
case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
|
|
623
|
+
case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region src/dotenv.ts
|
|
628
|
+
/**
|
|
629
|
+
* Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
|
|
630
|
+
* prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
|
|
631
|
+
* escapes; unquoted values drop trailing ` # comments`). Multiline values are
|
|
632
|
+
* not supported — keep those in seekrit itself.
|
|
633
|
+
*/
|
|
634
|
+
function parseDotenv(content) {
|
|
635
|
+
const out = {};
|
|
636
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
637
|
+
let line = raw.trim();
|
|
638
|
+
if (!line || line.startsWith("#")) continue;
|
|
639
|
+
if (line.startsWith("export ")) line = line.slice(7).trimStart();
|
|
640
|
+
const eq = line.indexOf("=");
|
|
641
|
+
if (eq === -1) continue;
|
|
642
|
+
const key = line.slice(0, eq).trim();
|
|
643
|
+
if (!key) continue;
|
|
644
|
+
let value = line.slice(eq + 1).trim();
|
|
645
|
+
const quote = value[0];
|
|
646
|
+
if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
|
|
647
|
+
value = value.slice(1, -1);
|
|
648
|
+
if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
649
|
+
} else {
|
|
650
|
+
const comment = value.indexOf(" #");
|
|
651
|
+
if (comment !== -1) value = value.slice(0, comment).trim();
|
|
652
|
+
}
|
|
653
|
+
out[key] = value;
|
|
654
|
+
}
|
|
655
|
+
return out;
|
|
656
|
+
}
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/secrets.ts
|
|
659
|
+
/** Fetch + decrypt every secret in a single environment. */
|
|
588
660
|
async function fetchDecryptedSecrets(ctx, orgId, envId) {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
]);
|
|
593
|
-
const entries = await Promise.all(
|
|
594
|
-
secrets2.map(
|
|
595
|
-
async (secret) => [
|
|
596
|
-
secret.name,
|
|
597
|
-
await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))
|
|
598
|
-
]
|
|
599
|
-
)
|
|
600
|
-
);
|
|
601
|
-
return Object.fromEntries(entries);
|
|
661
|
+
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
662
|
+
const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
|
|
663
|
+
return Object.fromEntries(entries);
|
|
602
664
|
}
|
|
603
665
|
async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
666
|
+
const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
|
|
667
|
+
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Resolve the full, layered environment for a running app: composed group
|
|
671
|
+
* secrets (lowest precedence) → the app env's own secrets → `.env` files.
|
|
672
|
+
* Each layer's DEK is unwrapped once with the principal's private key and its
|
|
673
|
+
* ciphertext decrypted locally. `process.env` is NOT applied here — callers
|
|
674
|
+
* that spawn a process layer it on top so the live shell always wins.
|
|
675
|
+
*/
|
|
676
|
+
async function materializeEnv(ctx, opts) {
|
|
677
|
+
const query = {};
|
|
678
|
+
if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
|
|
679
|
+
if (!isTokenAuth(ctx)) {
|
|
680
|
+
if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
|
|
681
|
+
query.env = opts.envId;
|
|
682
|
+
}
|
|
683
|
+
const { scope, layers } = await ctx.client.resolve(query);
|
|
684
|
+
const privateKey = await getPrivateKey(ctx);
|
|
685
|
+
const values = {};
|
|
686
|
+
const provenance = {};
|
|
687
|
+
for (const layer of layers) {
|
|
688
|
+
const dek = await unwrapDek(layer.wrappedDek, privateKey);
|
|
689
|
+
const label = layer.source === "group" ? `group:${layer.groupSlug}@${layer.slug}` : `app:${scope.appSlug}/${layer.slug}`;
|
|
690
|
+
for (const secret of layer.secrets) {
|
|
691
|
+
values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
|
|
692
|
+
provenance[secret.name] = label;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
values,
|
|
697
|
+
provenance,
|
|
698
|
+
scope,
|
|
699
|
+
loadedEnvFiles: overlayEnvFiles(values, provenance, opts.envFiles)
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Overlay `.env` files onto an existing value/provenance set (later files win).
|
|
704
|
+
* Missing files are skipped. Returns the files that were actually loaded. Used
|
|
705
|
+
* both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
|
|
706
|
+
* managed secrets are unavailable but `.env` should still apply.
|
|
707
|
+
*/
|
|
708
|
+
function overlayEnvFiles(values, provenance, envFiles) {
|
|
709
|
+
const loaded = [];
|
|
710
|
+
for (const file of envFiles) {
|
|
711
|
+
if (!existsSync(file)) continue;
|
|
712
|
+
loaded.push(file);
|
|
713
|
+
for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
|
|
714
|
+
values[name] = value;
|
|
715
|
+
provenance[name] = `dotenv:${file}`;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return loaded;
|
|
719
|
+
}
|
|
720
|
+
/** Print a name → source table to stderr (never the secret values). */
|
|
721
|
+
function printExplain(provenance) {
|
|
722
|
+
const names = Object.keys(provenance).sort();
|
|
723
|
+
const width = names.reduce((w, n) => Math.max(w, n.length), 0);
|
|
724
|
+
for (const name of names) process.stderr.write(`${name.padEnd(width)} ${provenance[name]}\n`);
|
|
725
|
+
}
|
|
726
|
+
//#endregion
|
|
727
|
+
//#region src/target.ts
|
|
728
|
+
/** Resolve the target org from a flag, the committed config, or a lone org. */
|
|
729
|
+
async function resolveOrg(ctx, orgSlug) {
|
|
730
|
+
const wanted = orgSlug ?? findProjectConfig()?.org;
|
|
731
|
+
const { orgs } = await ctx.client.listOrgs();
|
|
732
|
+
if (wanted) {
|
|
733
|
+
const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
|
|
734
|
+
if (!org) fail(`no accessible org "${wanted}"`);
|
|
735
|
+
return {
|
|
736
|
+
id: org.id,
|
|
737
|
+
slug: org.slug
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
const only = orgs[0];
|
|
741
|
+
if (orgs.length === 1 && only) return {
|
|
742
|
+
id: only.id,
|
|
743
|
+
slug: only.slug
|
|
744
|
+
};
|
|
745
|
+
fail("specify --org (or run `seekrit init`)");
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Resolve an environment to operate on — an application env (`--app --env`,
|
|
749
|
+
* or the config's app + `--env`) or a group env (`--group --env`).
|
|
750
|
+
*/
|
|
751
|
+
async function resolveEnvTarget(ctx, opts) {
|
|
752
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
753
|
+
if (!opts.env) fail("specify --env");
|
|
754
|
+
if (opts.group) {
|
|
755
|
+
const { groups } = await ctx.client.listGroups(org.id);
|
|
756
|
+
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
757
|
+
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
758
|
+
const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
|
|
759
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
760
|
+
if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
|
|
761
|
+
return {
|
|
762
|
+
orgId: org.id,
|
|
763
|
+
envId: env.id,
|
|
764
|
+
label: `${group.slug}@${env.slug}`
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
768
|
+
if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
|
|
769
|
+
const { apps } = await ctx.client.listApps(org.id);
|
|
770
|
+
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
771
|
+
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
772
|
+
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
773
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
774
|
+
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
775
|
+
return {
|
|
776
|
+
orgId: org.id,
|
|
777
|
+
envId: env.id,
|
|
778
|
+
label: `${app.slug}/${env.slug}`
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/** Resolve an application environment, keeping ids + slugs (for token binding). */
|
|
782
|
+
async function resolveAppEnv(ctx, opts) {
|
|
783
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
784
|
+
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
785
|
+
if (!appSlug) fail("specify --app (or run `seekrit init`)");
|
|
786
|
+
if (!opts.env) fail("specify --env");
|
|
787
|
+
const { apps } = await ctx.client.listApps(org.id);
|
|
788
|
+
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
789
|
+
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
790
|
+
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
791
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
792
|
+
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
793
|
+
return {
|
|
794
|
+
orgId: org.id,
|
|
795
|
+
appId: app.id,
|
|
796
|
+
appSlug: app.slug,
|
|
797
|
+
envId: env.id,
|
|
798
|
+
envSlug: env.slug
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
/** Resolve a group by slug within the target org. */
|
|
802
|
+
async function resolveGroup(ctx, opts) {
|
|
803
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
804
|
+
if (!opts.group) fail("specify --group");
|
|
805
|
+
const { groups } = await ctx.client.listGroups(org.id);
|
|
806
|
+
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
807
|
+
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
808
|
+
return {
|
|
809
|
+
orgId: org.id,
|
|
810
|
+
id: group.id,
|
|
811
|
+
slug: group.slug
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
//#endregion
|
|
815
|
+
//#region src/index.ts
|
|
816
|
+
/** Collect repeated `--with group=env` flags into a map. */
|
|
817
|
+
function collectKv(value, acc = {}) {
|
|
818
|
+
const eq = value.indexOf("=");
|
|
819
|
+
if (eq === -1) fail(`expected group=env, got "${value}"`);
|
|
820
|
+
acc[value.slice(0, eq)] = value.slice(eq + 1);
|
|
821
|
+
return acc;
|
|
822
|
+
}
|
|
823
|
+
/** Collect repeated flags (e.g. --env-file) into a list. */
|
|
824
|
+
function collectList(value, acc = []) {
|
|
825
|
+
acc.push(value);
|
|
826
|
+
return acc;
|
|
827
|
+
}
|
|
828
|
+
const program = new Command("seekrit").description("End-to-end encrypted secrets manager").version(version).enablePositionalOptions();
|
|
829
|
+
program.command("login").description("store credentials for the API").option("--token <token>", "service token (skt_…)").option("--dev-user <email>", "dev-mode identity (local API with AUTH_MODE=dev)").option("--api-url <url>", "API base URL").action((options) => {
|
|
830
|
+
if (options.token && !isServiceToken(options.token)) fail("token must start with skt_");
|
|
831
|
+
writeGlobalConfig({
|
|
832
|
+
...options.token ? { token: options.token } : {},
|
|
833
|
+
...options.devUser ? { devUser: options.devUser } : {},
|
|
834
|
+
...options.apiUrl ? { apiUrl: options.apiUrl } : {}
|
|
835
|
+
});
|
|
836
|
+
console.error("credentials saved");
|
|
626
837
|
});
|
|
627
838
|
program.command("whoami").description("show the authenticated identity").action(async () => {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
839
|
+
const ctx = buildContext();
|
|
840
|
+
if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
|
|
841
|
+
const { tokenId } = await parseServiceToken(ctx.auth.token);
|
|
842
|
+
try {
|
|
843
|
+
const { scope } = await ctx.client.resolve();
|
|
844
|
+
console.log(`service token ${tokenId} → ${scope.orgSlug}/${scope.appSlug}/${scope.envSlug}`);
|
|
845
|
+
} catch {
|
|
846
|
+
const { orgs } = await ctx.client.listOrgs();
|
|
847
|
+
console.log(`service token ${tokenId} (org: ${orgs[0]?.slug ?? "unknown"})`);
|
|
848
|
+
}
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const { user, orgs } = await ctx.client.me();
|
|
852
|
+
console.log(`${user.email}${user.hasKeys ? "" : " (key setup pending — run `seekrit keys setup`)"}`);
|
|
853
|
+
for (const org of orgs) console.log(` ${org.slug} (${org.role})`);
|
|
854
|
+
});
|
|
855
|
+
program.command("keys").description("manage your encryption keys").command("setup").description("generate your keypair and protect it with a passphrase").action(async () => {
|
|
856
|
+
const ctx = buildContext();
|
|
857
|
+
const passphrase = process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("New passphrase: ");
|
|
858
|
+
if (!process.env.SEEKRIT_PASSPHRASE) {
|
|
859
|
+
if (await promptHidden("Confirm passphrase: ") !== passphrase) fail("passphrases do not match");
|
|
860
|
+
}
|
|
861
|
+
if (passphrase.length < 8) fail("passphrase must be at least 8 characters");
|
|
862
|
+
const pair = await generateKeyPair();
|
|
863
|
+
const encryptedPrivateKey = await encryptPrivateKey(passphrase, pair.privateKeyJwk);
|
|
864
|
+
await ctx.client.setMyKeys({
|
|
865
|
+
publicKeyJwk: pair.publicKeyJwk,
|
|
866
|
+
encryptedPrivateKey
|
|
867
|
+
});
|
|
868
|
+
console.error("keys generated and uploaded — your passphrase never leaves this machine");
|
|
642
869
|
});
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
passphrase,
|
|
655
|
-
pair.privateKeyJwk
|
|
656
|
-
);
|
|
657
|
-
await ctx.client.setMyKeys({
|
|
658
|
-
publicKeyJwk: pair.publicKeyJwk,
|
|
659
|
-
encryptedPrivateKey
|
|
660
|
-
});
|
|
661
|
-
console.error(
|
|
662
|
-
"keys generated and uploaded \u2014 your passphrase never leaves this machine"
|
|
663
|
-
);
|
|
870
|
+
program.command("init").description("link this directory to an org/app (environment comes from the token)").requiredOption("--org <slug>", "organization slug").requiredOption("--app <slug>", "application slug").action(async (options) => {
|
|
871
|
+
const ctx = buildContext();
|
|
872
|
+
const org = await resolveOrg(ctx, options.org);
|
|
873
|
+
const { apps } = await ctx.client.listApps(org.id);
|
|
874
|
+
const app = apps.find((a) => a.slug === options.app || a.id === options.app);
|
|
875
|
+
if (!app) fail(`no app "${options.app}" in ${org.slug}`);
|
|
876
|
+
const path = writeProjectConfig({
|
|
877
|
+
org: org.slug,
|
|
878
|
+
app: app.slug
|
|
879
|
+
});
|
|
880
|
+
console.error(`linked ${org.slug}/${app.slug} → ${path} (environment is selected by the service token at runtime)`);
|
|
664
881
|
});
|
|
665
|
-
program.command("
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
if (!org2) fail(`no accessible org "${options.org}"`);
|
|
672
|
-
const { apps } = await ctx.client.listApps(org2.id);
|
|
673
|
-
const app2 = apps.find(
|
|
674
|
-
(a) => a.slug === options.app || a.id === options.app
|
|
675
|
-
);
|
|
676
|
-
if (!app2) fail(`no app "${options.app}" in ${org2.slug}`);
|
|
677
|
-
const { environments } = await ctx.client.listEnvs(org2.id, app2.id);
|
|
678
|
-
const env2 = environments.find(
|
|
679
|
-
(e) => e.slug === options.env || e.id === options.env
|
|
680
|
-
);
|
|
681
|
-
if (!env2) fail(`no environment "${options.env}" in ${app2.slug}`);
|
|
682
|
-
const path = writeProjectConfig({
|
|
683
|
-
orgId: org2.id,
|
|
684
|
-
appId: app2.id,
|
|
685
|
-
envId: env2.id,
|
|
686
|
-
org: org2.slug,
|
|
687
|
-
app: app2.slug,
|
|
688
|
-
env: env2.slug
|
|
689
|
-
});
|
|
690
|
-
console.error(`linked ${org2.slug}/${app2.slug}/${env2.slug} \u2192 ${path}`);
|
|
882
|
+
program.command("org").description("manage organizations").command("create").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
|
|
883
|
+
const created = await buildContext().client.createOrg({
|
|
884
|
+
name: options.name,
|
|
885
|
+
slug: options.slug
|
|
886
|
+
});
|
|
887
|
+
console.error(`created org ${created.org.slug} (${created.org.id})`);
|
|
691
888
|
});
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
889
|
+
program.command("app").description("manage applications").command("create").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
|
|
890
|
+
const ctx = buildContext();
|
|
891
|
+
const orgRef = await resolveOrg(ctx, options.org);
|
|
892
|
+
const created = await ctx.client.createApp(orgRef.id, {
|
|
893
|
+
name: options.name,
|
|
894
|
+
slug: options.slug
|
|
895
|
+
});
|
|
896
|
+
console.error(`created app ${created.app.slug} (${created.app.id})`);
|
|
700
897
|
});
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
898
|
+
const env = program.command("env").description("manage environments");
|
|
899
|
+
env.command("create").description("create an application environment (generates its data key locally)").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
|
|
900
|
+
const ctx = buildContext();
|
|
901
|
+
const orgRef = await resolveOrg(ctx, options.org);
|
|
902
|
+
const { apps } = await ctx.client.listApps(orgRef.id);
|
|
903
|
+
const appRow = apps.find((a) => a.slug === options.app || a.id === options.app);
|
|
904
|
+
if (!appRow) fail(`no app "${options.app}" in ${orgRef.slug}`);
|
|
905
|
+
const { user } = await ctx.client.me();
|
|
906
|
+
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
907
|
+
const wrappedDek = await wrapDek(generateDek(), user.publicKeyJwk);
|
|
908
|
+
const created = await ctx.client.createEnv(orgRef.id, appRow.id, {
|
|
909
|
+
name: options.name,
|
|
910
|
+
slug: options.slug,
|
|
911
|
+
wrappedDek
|
|
912
|
+
});
|
|
913
|
+
console.error(`created environment ${created.environment.slug} (${created.environment.id})`);
|
|
714
914
|
});
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
if (!appRow) fail(`no app "${options.app}" in ${orgRow.slug}`);
|
|
729
|
-
const { user } = await ctx.client.me();
|
|
730
|
-
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
731
|
-
const dek = generateDek();
|
|
732
|
-
const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
|
|
733
|
-
const created = await ctx.client.createEnv(orgRow.id, appRow.id, {
|
|
734
|
-
name: options.name,
|
|
735
|
-
slug: options.slug,
|
|
736
|
-
wrappedDek
|
|
737
|
-
});
|
|
738
|
-
console.error(
|
|
739
|
-
`created environment ${created.environment.slug} (${created.environment.id})`
|
|
740
|
-
);
|
|
741
|
-
}
|
|
742
|
-
);
|
|
743
|
-
var secrets = program.command("secrets").description("manage secrets in the linked environment");
|
|
744
|
-
secrets.command("list").description("list secret names (no values)").action(async () => {
|
|
745
|
-
const ctx = buildContext();
|
|
746
|
-
const project = requireProject();
|
|
747
|
-
const { secrets: rows } = await ctx.client.listSecrets(
|
|
748
|
-
project.orgId,
|
|
749
|
-
project.envId
|
|
750
|
-
);
|
|
751
|
-
for (const row of rows)
|
|
752
|
-
console.log(`${row.name} v${row.version} ${row.updatedAt}`);
|
|
915
|
+
const envGroups = env.command("groups").description("compose shared groups into an application environment");
|
|
916
|
+
envGroups.command("add").description("compose a group into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").requiredOption("--group <slug>").option("--position <n>", "precedence among groups (higher wins)").action(async (options) => {
|
|
917
|
+
const ctx = buildContext();
|
|
918
|
+
const target = await resolveAppEnv(ctx, options);
|
|
919
|
+
const group = await resolveGroup(ctx, {
|
|
920
|
+
org: options.org,
|
|
921
|
+
group: options.group
|
|
922
|
+
});
|
|
923
|
+
await ctx.client.linkEnvGroup(target.orgId, target.envId, {
|
|
924
|
+
groupId: group.id,
|
|
925
|
+
position: options.position === void 0 ? void 0 : Number.parseInt(options.position, 10)
|
|
926
|
+
});
|
|
927
|
+
console.error(`composed ${group.slug} into ${target.appSlug}/${target.envSlug}`);
|
|
753
928
|
});
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
project.orgId,
|
|
760
|
-
project.envId
|
|
761
|
-
);
|
|
762
|
-
const value = values[name];
|
|
763
|
-
if (value === void 0) fail(`no secret named ${name}`);
|
|
764
|
-
process.stdout.write(value);
|
|
765
|
-
if (process.stdout.isTTY) process.stdout.write("\n");
|
|
929
|
+
envGroups.command("list").description("list groups composed into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").action(async (options) => {
|
|
930
|
+
const ctx = buildContext();
|
|
931
|
+
const target = await resolveAppEnv(ctx, options);
|
|
932
|
+
const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
|
|
933
|
+
for (const g of groups) console.log(`${g.position}\t${g.slug}\t${g.name}`);
|
|
766
934
|
});
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
project.envId,
|
|
777
|
-
name,
|
|
778
|
-
plaintext
|
|
779
|
-
);
|
|
780
|
-
console.error(`${name} saved`);
|
|
935
|
+
envGroups.command("rm").description("remove a group from an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").requiredOption("--group <slug>").action(async (options) => {
|
|
936
|
+
const ctx = buildContext();
|
|
937
|
+
const target = await resolveAppEnv(ctx, options);
|
|
938
|
+
const group = await resolveGroup(ctx, {
|
|
939
|
+
org: options.org,
|
|
940
|
+
group: options.group
|
|
941
|
+
});
|
|
942
|
+
await ctx.client.unlinkEnvGroup(target.orgId, target.envId, group.id);
|
|
943
|
+
console.error(`removed ${group.slug} from ${target.appSlug}/${target.envSlug}`);
|
|
781
944
|
});
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
945
|
+
const group = program.command("group").description("manage shared groups (reusable secret bags)");
|
|
946
|
+
group.command("create").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
|
|
947
|
+
const ctx = buildContext();
|
|
948
|
+
const orgRef = await resolveOrg(ctx, options.org);
|
|
949
|
+
const created = await ctx.client.createGroup(orgRef.id, {
|
|
950
|
+
name: options.name,
|
|
951
|
+
slug: options.slug
|
|
952
|
+
});
|
|
953
|
+
console.error(`created group ${created.group.slug} (${created.group.id})`);
|
|
787
954
|
});
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
955
|
+
group.command("env").description("manage a group’s environments (per-slug value sets / variants)").command("create").description("create a group environment (generates its data key locally)").option("--org <slug>").requiredOption("--group <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
|
|
956
|
+
const ctx = buildContext();
|
|
957
|
+
const groupRef = await resolveGroup(ctx, {
|
|
958
|
+
org: options.org,
|
|
959
|
+
group: options.group
|
|
960
|
+
});
|
|
961
|
+
const { user } = await ctx.client.me();
|
|
962
|
+
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
963
|
+
const wrappedDek = await wrapDek(generateDek(), user.publicKeyJwk);
|
|
964
|
+
const created = await ctx.client.createGroupEnv(groupRef.orgId, groupRef.id, {
|
|
965
|
+
name: options.name,
|
|
966
|
+
slug: options.slug,
|
|
967
|
+
wrappedDek
|
|
968
|
+
});
|
|
969
|
+
console.error(`created ${groupRef.slug}@${created.environment.slug} (${created.environment.id})`);
|
|
970
|
+
});
|
|
971
|
+
/** Attach the environment-selection flags shared by every `secrets` command. */
|
|
972
|
+
function withTarget(cmd) {
|
|
973
|
+
return cmd.option("--org <slug>", "organization slug").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").requiredOption("--env <slug>", "environment slug");
|
|
974
|
+
}
|
|
975
|
+
const secrets = program.command("secrets").description("manage secrets in an application or group environment");
|
|
976
|
+
withTarget(secrets.command("list").description("list secret names (no values)")).action(async (options) => {
|
|
977
|
+
const ctx = buildContext();
|
|
978
|
+
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
979
|
+
const { secrets: rows } = await ctx.client.listSecrets(orgId, envId);
|
|
980
|
+
for (const row of rows) console.log(`${row.name}\tv${row.version}\t${row.updatedAt}`);
|
|
981
|
+
});
|
|
982
|
+
withTarget(secrets.command("get <name>").description("decrypt and print one secret value")).action(async (name, options) => {
|
|
983
|
+
const ctx = buildContext();
|
|
984
|
+
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
985
|
+
const value = (await fetchDecryptedSecrets(ctx, orgId, envId))[name];
|
|
986
|
+
if (value === void 0) fail(`no secret named ${name}`);
|
|
987
|
+
process.stdout.write(value);
|
|
988
|
+
if (process.stdout.isTTY) process.stdout.write("\n");
|
|
989
|
+
});
|
|
990
|
+
withTarget(secrets.command("set <name> [value]").description("encrypt and store a secret (reads stdin when value is omitted or '-')")).action(async (name, value, options) => {
|
|
991
|
+
const ctx = buildContext();
|
|
992
|
+
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
993
|
+
await encryptAndSetSecret(ctx, orgId, envId, name, value === void 0 || value === "-" ? (await readStdin()).replace(/\n$/, "") : value);
|
|
994
|
+
console.error(`${name} saved`);
|
|
995
|
+
});
|
|
996
|
+
withTarget(secrets.command("rm <name>").description("delete a secret")).action(async (name, options) => {
|
|
997
|
+
const ctx = buildContext();
|
|
998
|
+
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
999
|
+
await ctx.client.deleteSecret(orgId, envId, name);
|
|
1000
|
+
console.error(`${name} deleted`);
|
|
1001
|
+
});
|
|
1002
|
+
/** Resolve the layered environment for the current principal. */
|
|
1003
|
+
async function materialize(ctx, options) {
|
|
1004
|
+
let envId;
|
|
1005
|
+
if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, options)).envId;
|
|
1006
|
+
return materializeEnv(ctx, {
|
|
1007
|
+
envId,
|
|
1008
|
+
with: options.with,
|
|
1009
|
+
envFiles: options.envFile ?? [".env"]
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Best-effort variant of {@link materialize} for `seekrit run`: if credentials
|
|
1014
|
+
* are missing or seekrit can't be reached (network, auth, or decryption
|
|
1015
|
+
* failure), log why and fall back to the `.env` overlay alone so the command
|
|
1016
|
+
* still runs. This keeps `seekrit run` working across environments that lack a
|
|
1017
|
+
* token or API — mirroring the `seekrit-run` launcher.
|
|
1018
|
+
*/
|
|
1019
|
+
async function materializeForRun(options) {
|
|
1020
|
+
const envFiles = options.envFile ?? [".env"];
|
|
1021
|
+
try {
|
|
1022
|
+
const ctx = tryBuildContext();
|
|
1023
|
+
if (!ctx) throw new Error("no credentials found (set SEEKRIT_TOKEN / SEEKRIT_DEV_USER or run `seekrit login`)");
|
|
1024
|
+
return await materialize(ctx, options);
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1027
|
+
console.error(`seekrit: continuing without seekrit-managed secrets: ${message}`);
|
|
1028
|
+
const values = {};
|
|
1029
|
+
const provenance = {};
|
|
1030
|
+
overlayEnvFiles(values, provenance, envFiles);
|
|
1031
|
+
return {
|
|
1032
|
+
values,
|
|
1033
|
+
provenance
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
|
|
1038
|
+
const [cmd, ...args] = commandParts;
|
|
1039
|
+
if (!cmd) fail("no command given");
|
|
1040
|
+
const { values, provenance } = await materializeForRun(options);
|
|
1041
|
+
if (options.explain) {
|
|
1042
|
+
for (const name of Object.keys(values)) if (process.env[name] !== void 0 && process.env[name] !== values[name]) provenance[name] = "env";
|
|
1043
|
+
printExplain(provenance);
|
|
1044
|
+
}
|
|
1045
|
+
const child = spawn(cmd, args, {
|
|
1046
|
+
stdio: "inherit",
|
|
1047
|
+
env: {
|
|
1048
|
+
...values,
|
|
1049
|
+
...process.env
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
child.on("exit", (code, signal) => {
|
|
1053
|
+
if (signal) process.kill(process.pid, signal);
|
|
1054
|
+
process.exit(code ?? 1);
|
|
1055
|
+
});
|
|
1056
|
+
child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
|
|
809
1057
|
});
|
|
810
|
-
program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
project.envId
|
|
820
|
-
);
|
|
821
|
-
console.log(formatSecrets(values, options.format));
|
|
1058
|
+
program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
|
|
1059
|
+
if (![
|
|
1060
|
+
"dotenv",
|
|
1061
|
+
"json",
|
|
1062
|
+
"shell"
|
|
1063
|
+
].includes(options.format)) fail("format must be dotenv, json, or shell");
|
|
1064
|
+
const { values, provenance } = await materialize(buildContext(), options);
|
|
1065
|
+
if (options.explain) printExplain(provenance);
|
|
1066
|
+
console.log(formatSecrets(values, options.format));
|
|
822
1067
|
});
|
|
823
|
-
program.command("grant").description(
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
principalId,
|
|
859
|
-
wrappedDek
|
|
860
|
-
});
|
|
861
|
-
console.error(
|
|
862
|
-
`granted ${project.env ?? project.envId} access to ${principalId}`
|
|
863
|
-
);
|
|
1068
|
+
program.command("grant").description("give a member or service token access to an environment's key").option("--org <slug>").option("--app <slug>").option("--group <slug>", "grant a group environment instead of an app").requiredOption("--env <slug>").option("--user <email>", "grant to an org member by email").option("--token <tokenId>", "grant to a service token by id (skt_…)").action(async (options) => {
|
|
1069
|
+
if (!options.user === !options.token) fail("pass exactly one of --user or --token");
|
|
1070
|
+
const ctx = buildContext();
|
|
1071
|
+
const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
|
|
1072
|
+
const dek = await getDek(ctx, orgId, envId);
|
|
1073
|
+
let principalType;
|
|
1074
|
+
let principalId;
|
|
1075
|
+
let publicKeyJwk;
|
|
1076
|
+
if (options.user) {
|
|
1077
|
+
const { members } = await ctx.client.listMembers(orgId);
|
|
1078
|
+
const member = members.find((m) => m.email === options.user);
|
|
1079
|
+
if (!member) fail(`no member ${options.user}`);
|
|
1080
|
+
if (!member.publicKeyJwk) fail(`${options.user} has not completed key setup`);
|
|
1081
|
+
[principalType, principalId, publicKeyJwk] = [
|
|
1082
|
+
"user",
|
|
1083
|
+
member.userId,
|
|
1084
|
+
member.publicKeyJwk
|
|
1085
|
+
];
|
|
1086
|
+
} else {
|
|
1087
|
+
const { tokens } = await ctx.client.listTokens(orgId);
|
|
1088
|
+
const token = tokens.find((t) => t.id === options.token);
|
|
1089
|
+
if (!token) fail(`no service token ${options.token}`);
|
|
1090
|
+
[principalType, principalId, publicKeyJwk] = [
|
|
1091
|
+
"service_token",
|
|
1092
|
+
token.id,
|
|
1093
|
+
token.publicKeyJwk
|
|
1094
|
+
];
|
|
1095
|
+
}
|
|
1096
|
+
const wrappedDek = await wrapDek(dek, publicKeyJwk);
|
|
1097
|
+
await ctx.client.grantEnvKey(orgId, envId, {
|
|
1098
|
+
principalType,
|
|
1099
|
+
principalId,
|
|
1100
|
+
wrappedDek
|
|
1101
|
+
});
|
|
1102
|
+
console.error(`granted ${label} access to ${principalId}`);
|
|
864
1103
|
});
|
|
865
|
-
|
|
866
|
-
token.command("create").description("create a service token; prints
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1104
|
+
const token = program.command("token").description("manage service tokens (CI, docker, agents)");
|
|
1105
|
+
token.command("create").description("create a service token bound to an app environment; prints it once").requiredOption("--name <name>", "display name, e.g. ci-deploy").option("--org <slug>").requiredOption("--app <slug>", "application to bind the token to").requiredOption("--env <slug>", "environment to bind the token to").option("--allow <group=env>", "also grant an alternate group slice (for `run --with`)", collectKv).option("--no-grant", "skip auto-granting the env + composed group keys").action(async (options) => {
|
|
1106
|
+
const ctx = buildContext();
|
|
1107
|
+
const target = await resolveAppEnv(ctx, options);
|
|
1108
|
+
const created = await createServiceToken();
|
|
1109
|
+
await ctx.client.createToken(target.orgId, {
|
|
1110
|
+
name: options.name,
|
|
1111
|
+
tokenId: created.tokenId,
|
|
1112
|
+
tokenHash: created.tokenHash,
|
|
1113
|
+
publicKeyJwk: created.publicKeyJwk,
|
|
1114
|
+
environmentId: target.envId
|
|
1115
|
+
});
|
|
1116
|
+
const grantEnv = async (envId) => {
|
|
1117
|
+
const dek = await getDek(ctx, target.orgId, envId);
|
|
1118
|
+
await ctx.client.grantEnvKey(target.orgId, envId, {
|
|
1119
|
+
principalType: "service_token",
|
|
1120
|
+
principalId: created.tokenId,
|
|
1121
|
+
wrappedDek: await wrapDek(dek, created.publicKeyJwk)
|
|
1122
|
+
});
|
|
1123
|
+
};
|
|
1124
|
+
if (options.grant !== false) {
|
|
1125
|
+
await grantEnv(target.envId);
|
|
1126
|
+
const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
|
|
1127
|
+
for (const g of groups) {
|
|
1128
|
+
const { environments } = await ctx.client.listGroupEnvs(target.orgId, g.groupId);
|
|
1129
|
+
const slice = environments.find((e) => e.slug === target.envSlug);
|
|
1130
|
+
if (!slice) fail(`group "${g.slug}" has no "${target.envSlug}" environment — create it before granting`);
|
|
1131
|
+
await grantEnv(slice.id);
|
|
1132
|
+
}
|
|
1133
|
+
for (const [groupSlug, envSlug] of Object.entries(options.allow ?? {})) {
|
|
1134
|
+
const groupRef = await resolveGroup(ctx, {
|
|
1135
|
+
org: options.org,
|
|
1136
|
+
group: groupSlug
|
|
1137
|
+
});
|
|
1138
|
+
const { environments } = await ctx.client.listGroupEnvs(target.orgId, groupRef.id);
|
|
1139
|
+
const slice = environments.find((e) => e.slug === envSlug);
|
|
1140
|
+
if (!slice) fail(`group "${groupSlug}" has no "${envSlug}" environment`);
|
|
1141
|
+
await grantEnv(slice.id);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
console.error(`token created${options.grant !== false ? " and granted" : ""} for ${target.appSlug}/${target.envSlug} — save it now, it is not stored:`);
|
|
1145
|
+
console.log(created.token);
|
|
889
1146
|
});
|
|
890
|
-
token.command("list").description("list service tokens").action(async () => {
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
);
|
|
899
|
-
}
|
|
1147
|
+
token.command("list").description("list service tokens").option("--org <slug>").action(async (options) => {
|
|
1148
|
+
const ctx = buildContext();
|
|
1149
|
+
const orgRef = await resolveOrg(ctx, options.org);
|
|
1150
|
+
const { tokens } = await ctx.client.listTokens(orgRef.id);
|
|
1151
|
+
for (const t of tokens) {
|
|
1152
|
+
const status = t.revokedAt ? "revoked" : t.expiresAt && Date.parse(t.expiresAt) < Date.now() ? "expired" : "active";
|
|
1153
|
+
console.log(`${t.id}\t${t.name}\t${status}\tlast used: ${t.lastUsedAt ?? "never"}`);
|
|
1154
|
+
}
|
|
900
1155
|
});
|
|
901
|
-
token.command("revoke <tokenId>").description("revoke a service token").action(async (tokenId) => {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1156
|
+
token.command("revoke <tokenId>").description("revoke a service token").option("--org <slug>").action(async (tokenId, options) => {
|
|
1157
|
+
const ctx = buildContext();
|
|
1158
|
+
const orgRef = await resolveOrg(ctx, options.org);
|
|
1159
|
+
await ctx.client.revokeToken(orgRef.id, tokenId);
|
|
1160
|
+
console.error(`${tokenId} revoked`);
|
|
906
1161
|
});
|
|
907
|
-
program.command("audit").description("show the org audit trail").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
});
|
|
913
|
-
for (const entry of entries) {
|
|
914
|
-
console.log(
|
|
915
|
-
`${entry.createdAt} ${entry.action} ${entry.actorType}:${entry.actorId} ${entry.resourceType}${entry.resourceId ? `:${entry.resourceId}` : ""}`
|
|
916
|
-
);
|
|
917
|
-
}
|
|
1162
|
+
program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
|
|
1163
|
+
const ctx = buildContext();
|
|
1164
|
+
const orgRef = await resolveOrg(ctx, options.org);
|
|
1165
|
+
const { entries } = await ctx.client.listAudit(orgRef.id, { limit: Number.parseInt(options.limit, 10) || 50 });
|
|
1166
|
+
for (const entry of entries) console.log(`${entry.createdAt}\t${entry.action}\t${entry.actorType}:${entry.actorId}\t${entry.resourceType}${entry.resourceId ? `:${entry.resourceId}` : ""}`);
|
|
918
1167
|
});
|
|
919
1168
|
program.parseAsync().catch((err) => {
|
|
920
|
-
|
|
1169
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
921
1170
|
});
|
|
1171
|
+
//#endregion
|
|
1172
|
+
export {};
|