@forgezero/vault 0.1.0 → 0.1.2
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 +15 -14
- package/dist/index.d.ts +113 -40
- package/dist/index.js +239 -50
- package/dist/providers.js +234 -48
- package/dist/schema.js +234 -48
- package/package.json +11 -3
package/dist/schema.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
+
import { deriveKeysFromSeed, signRequest } from "@forgezero/runtime/identity";
|
|
3
|
+
|
|
2
4
|
class VaultError extends Error {
|
|
3
5
|
code;
|
|
4
6
|
constructor(code, message) {
|
|
@@ -7,76 +9,171 @@ class VaultError extends Error {
|
|
|
7
9
|
this.name = "VaultError";
|
|
8
10
|
}
|
|
9
11
|
}
|
|
10
|
-
var
|
|
12
|
+
var runtimeEnvironment = () => typeof process !== "undefined" && process.env ? process.env : {};
|
|
13
|
+
var runtimeSocketExists = (path) => {
|
|
14
|
+
if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function")
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
const fs = process.getBuiltinModule("node:fs");
|
|
18
|
+
return fs.existsSync(path);
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var DEFAULT_SOCKET = "/run/forgezero/vault.sock";
|
|
11
24
|
function discover(environment = {}) {
|
|
12
|
-
const env = environment.env ??
|
|
25
|
+
const env = environment.env ?? runtimeEnvironment();
|
|
13
26
|
const socketPath = environment.socketPath ?? env.FORGEZERO_SOCKET ?? DEFAULT_SOCKET;
|
|
14
|
-
if (environment.socketExists
|
|
27
|
+
if ((environment.socketExists ?? runtimeSocketExists)(socketPath))
|
|
15
28
|
return { mode: "managed", socketPath };
|
|
16
29
|
const apiKey = env.FORGEZERO_API_KEY;
|
|
17
30
|
if (apiKey)
|
|
18
31
|
return { mode: "external", apiKey };
|
|
19
32
|
throw new VaultError("NO_CREDENTIAL", `No credential found. On managed compute the agent socket at ${socketPath} provides one; ` + "elsewhere set FORGEZERO_API_KEY.");
|
|
20
33
|
}
|
|
21
|
-
var
|
|
22
|
-
|
|
34
|
+
var unbase64url = (encoded) => {
|
|
35
|
+
const padded = encoded.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - encoded.length % 4) % 4);
|
|
36
|
+
let binary;
|
|
37
|
+
try {
|
|
38
|
+
binary = atob(padded);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
|
|
41
|
+
}
|
|
42
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
43
|
+
};
|
|
44
|
+
var base64url = (value) => {
|
|
45
|
+
const bytes = new TextEncoder().encode(value);
|
|
46
|
+
let binary = "";
|
|
47
|
+
for (const byte of bytes)
|
|
48
|
+
binary += String.fromCharCode(byte);
|
|
49
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
50
|
+
};
|
|
51
|
+
function signerFromApiKey(secret) {
|
|
52
|
+
const parts = secret.split(".");
|
|
53
|
+
const legacy = parts.length === 3 && parts[0] === "fz";
|
|
54
|
+
const current = parts.length === 4 && parts[0] === "fz" && (parts[1] === "live" || parts[1] === "test");
|
|
55
|
+
if (!legacy && !current)
|
|
56
|
+
throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
|
|
57
|
+
const keyId = parts[current ? 2 : 1];
|
|
58
|
+
const encodedSeed = parts[current ? 3 : 2];
|
|
59
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(keyId) || !/^[A-Za-z0-9_-]{43}$/.test(encodedSeed)) {
|
|
60
|
+
throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
|
|
61
|
+
}
|
|
62
|
+
const seed = unbase64url(encodedSeed);
|
|
63
|
+
if (seed.length !== 32)
|
|
64
|
+
throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
|
|
65
|
+
const keys = deriveKeysFromSeed(seed);
|
|
66
|
+
seed.fill(0);
|
|
67
|
+
return {
|
|
68
|
+
keyId,
|
|
69
|
+
async sign(request) {
|
|
70
|
+
const envelope = signRequest(keys, keyId, request);
|
|
71
|
+
return base64url(JSON.stringify({
|
|
72
|
+
timestamp: envelope.timestamp,
|
|
73
|
+
nonce: envelope.nonce,
|
|
74
|
+
edSignature: envelope.edSignature,
|
|
75
|
+
mlDsaSignature: envelope.mlDsaSignature
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
var requestAgent = (socketPath, request) => new Promise((resolveRequest, reject) => {
|
|
81
|
+
if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function") {
|
|
82
|
+
reject(new VaultError("MANAGED_RUNTIME_UNAVAILABLE", "The managed agent socket needs a Node or Bun server runtime."));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const net = process.getBuiltinModule("node:net");
|
|
86
|
+
const socket = net.connect(socketPath, () => socket.write(`${JSON.stringify(request)}
|
|
87
|
+
`));
|
|
88
|
+
let buffer = "";
|
|
89
|
+
socket.setTimeout(15000, () => {
|
|
90
|
+
socket.destroy();
|
|
91
|
+
reject(new VaultError("AGENT_TIMEOUT", "The local ForgeZero agent did not answer."));
|
|
92
|
+
});
|
|
93
|
+
socket.on("data", (chunk) => {
|
|
94
|
+
buffer += chunk.toString("utf8");
|
|
95
|
+
if (new TextEncoder().encode(buffer).byteLength > 256 * 1024) {
|
|
96
|
+
socket.destroy();
|
|
97
|
+
reject(new VaultError("AGENT_RESPONSE_TOO_LARGE", "The local ForgeZero agent response was too large."));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const newline = buffer.indexOf(`
|
|
101
|
+
`);
|
|
102
|
+
if (newline < 0)
|
|
103
|
+
return;
|
|
104
|
+
socket.end();
|
|
105
|
+
try {
|
|
106
|
+
resolveRequest(JSON.parse(buffer.slice(0, newline)));
|
|
107
|
+
} catch {
|
|
108
|
+
reject(new VaultError("AGENT_RESPONSE_MALFORMED", "The local ForgeZero agent response was malformed."));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
socket.on("error", (cause) => reject(new VaultError("AGENT_UNAVAILABLE", cause.message)));
|
|
112
|
+
});
|
|
113
|
+
var DEFAULT_API_URL = "https://api.forgezero.net";
|
|
114
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
|
115
|
+
function apiOrigin(value) {
|
|
116
|
+
let url;
|
|
117
|
+
try {
|
|
118
|
+
url = new URL(value);
|
|
119
|
+
} catch {
|
|
120
|
+
throw new VaultError("API_URL_INVALID", "apiUrl must be an absolute HTTPS origin.");
|
|
121
|
+
}
|
|
122
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
|
|
123
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:") || url.username || url.password || url.search || url.hash || url.pathname !== "/" && url.pathname !== "")
|
|
124
|
+
throw new VaultError("API_URL_INVALID", "apiUrl must be an HTTPS origin without credentials, path, query or fragment.");
|
|
125
|
+
return url;
|
|
126
|
+
}
|
|
23
127
|
|
|
24
128
|
class ForgeZero {
|
|
25
129
|
credential;
|
|
26
|
-
|
|
27
|
-
assignUrl;
|
|
130
|
+
apiUrl;
|
|
28
131
|
doFetch;
|
|
29
|
-
now;
|
|
30
132
|
signer;
|
|
133
|
+
agentTransport;
|
|
134
|
+
requestTimeoutMs;
|
|
31
135
|
project;
|
|
32
136
|
environment;
|
|
33
137
|
constructor(options = {}) {
|
|
34
|
-
|
|
35
|
-
this.
|
|
138
|
+
const credential = options.credential ?? discover(options.discovery);
|
|
139
|
+
this.credential = credential.mode === "managed" && !credential.socketPath ? { ...credential, socketPath: DEFAULT_SOCKET } : credential;
|
|
140
|
+
this.apiUrl = apiOrigin(options.apiUrl ?? DEFAULT_API_URL);
|
|
36
141
|
this.doFetch = options.fetch ?? globalThis.fetch;
|
|
37
|
-
this.
|
|
38
|
-
this.
|
|
142
|
+
this.signer = options.signer ?? (this.credential.mode === "external" ? signerFromApiKey(this.credential.apiKey) : undefined);
|
|
143
|
+
this.agentTransport = options.agentTransport ?? requestAgent;
|
|
144
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
145
|
+
if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 100 || this.requestTimeoutMs > 120000) {
|
|
146
|
+
throw new VaultError("INVALID_TIMEOUT", "requestTimeoutMs must be between 100 and 120000 milliseconds.");
|
|
147
|
+
}
|
|
39
148
|
this.project = options.project ?? "default";
|
|
40
149
|
this.environment = options.environment ?? "production";
|
|
41
150
|
}
|
|
42
|
-
async node() {
|
|
43
|
-
const now = this.now();
|
|
44
|
-
if (this.assignment && this.assignment.expiresAt > now)
|
|
45
|
-
return this.assignment.node;
|
|
46
|
-
const response = await this.doFetch(this.assignUrl, {
|
|
47
|
-
method: "POST",
|
|
48
|
-
headers: { "content-type": "application/json" },
|
|
49
|
-
body: JSON.stringify({ keyId: this.signer?.keyId })
|
|
50
|
-
});
|
|
51
|
-
if (!response.ok)
|
|
52
|
-
throw new VaultError("NO_NODE", "No vault node is available right now.");
|
|
53
|
-
const payload = await response.json();
|
|
54
|
-
const ttl = payload.ttl ?? 60;
|
|
55
|
-
this.assignment = { node: payload.node, ttl, expiresAt: now + ttl * 1000 };
|
|
56
|
-
return payload.node;
|
|
57
|
-
}
|
|
58
|
-
reassign() {
|
|
59
|
-
this.assignment = undefined;
|
|
60
|
-
}
|
|
61
151
|
scope() {
|
|
62
152
|
return `${encodeURIComponent(this.project)}/${encodeURIComponent(this.environment)}`;
|
|
63
153
|
}
|
|
64
|
-
async request(path, init = {}
|
|
65
|
-
|
|
154
|
+
async request(path, init = {}) {
|
|
155
|
+
if (this.credential.mode === "managed")
|
|
156
|
+
return this.managedRequest(path, init);
|
|
66
157
|
const body = typeof init.body === "string" ? init.body : "";
|
|
158
|
+
const target = new URL(path, this.apiUrl);
|
|
67
159
|
const headers = {
|
|
68
160
|
"content-type": "application/json",
|
|
69
161
|
...init.headers
|
|
70
162
|
};
|
|
71
163
|
if (this.signer) {
|
|
72
164
|
headers["x-fz-key"] = this.signer.keyId;
|
|
73
|
-
headers["x-fz-signature"] = await this.signer.sign(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
165
|
+
headers["x-fz-signature"] = await this.signer.sign({
|
|
166
|
+
method: init.method ?? "GET",
|
|
167
|
+
path: target.pathname,
|
|
168
|
+
query: target.search.replace(/^\?/, ""),
|
|
169
|
+
body
|
|
170
|
+
});
|
|
79
171
|
}
|
|
172
|
+
const response = await this.doFetch(target, {
|
|
173
|
+
...init,
|
|
174
|
+
headers,
|
|
175
|
+
signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs)
|
|
176
|
+
});
|
|
80
177
|
const payload = await response.json().catch(() => {
|
|
81
178
|
return;
|
|
82
179
|
});
|
|
@@ -85,7 +182,66 @@ class ForgeZero {
|
|
|
85
182
|
}
|
|
86
183
|
return payload;
|
|
87
184
|
}
|
|
185
|
+
async managedRequest(path, init) {
|
|
186
|
+
const socketPath = this.credential.socketPath;
|
|
187
|
+
const method = init.method ?? "GET";
|
|
188
|
+
const target = new URL(path, "https://vault.invalid");
|
|
189
|
+
const entry = /\/entries\/([^/]+)$/.exec(target.pathname);
|
|
190
|
+
let response;
|
|
191
|
+
if (method === "GET" && entry) {
|
|
192
|
+
response = await this.agentTransport(socketPath, {
|
|
193
|
+
op: "get",
|
|
194
|
+
name: decodeURIComponent(entry[1])
|
|
195
|
+
});
|
|
196
|
+
if (!response.ok)
|
|
197
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
198
|
+
if (response.op !== "get")
|
|
199
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
200
|
+
return { value: response.value };
|
|
201
|
+
}
|
|
202
|
+
if (method === "GET" && target.pathname.endsWith("/list")) {
|
|
203
|
+
response = await this.agentTransport(socketPath, { op: "held" });
|
|
204
|
+
if (!response.ok)
|
|
205
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
206
|
+
if (response.op !== "held")
|
|
207
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
208
|
+
return { entries: response.names.map((name) => ({ name })) };
|
|
209
|
+
}
|
|
210
|
+
if (method === "GET" && target.pathname.endsWith("/entries")) {
|
|
211
|
+
response = await this.agentTransport(socketPath, { op: "held" });
|
|
212
|
+
if (!response.ok)
|
|
213
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
214
|
+
if (response.op !== "held")
|
|
215
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
216
|
+
const values = {};
|
|
217
|
+
for (const name of response.names) {
|
|
218
|
+
const read = await this.agentTransport(socketPath, { op: "get", name });
|
|
219
|
+
if (!read.ok)
|
|
220
|
+
throw new VaultError(read.error.code, read.error.message);
|
|
221
|
+
if (read.op !== "get")
|
|
222
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
223
|
+
values[name] = read.value;
|
|
224
|
+
}
|
|
225
|
+
return { values };
|
|
226
|
+
}
|
|
227
|
+
if (method === "GET" && target.pathname.endsWith("/changes")) {
|
|
228
|
+
response = await this.agentTransport(socketPath, { op: "sync" });
|
|
229
|
+
if (!response.ok)
|
|
230
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
231
|
+
if (response.op !== "sync")
|
|
232
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
233
|
+
return {
|
|
234
|
+
version: response.cursor,
|
|
235
|
+
changed: response.invalidated.map((name) => ({ name, version: 0 })),
|
|
236
|
+
resync: response.resync
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
throw new VaultError("MANAGED_READ_ONLY", "This operation is not exposed to an application through the managed agent socket.");
|
|
240
|
+
}
|
|
88
241
|
async get(name, options = {}) {
|
|
242
|
+
if (this.credential.mode === "managed" && options.version !== undefined) {
|
|
243
|
+
throw new VaultError("MANAGED_CURRENT_ONLY", "The managed agent RAM replica serves the current value only; use an API key for a historical version.");
|
|
244
|
+
}
|
|
89
245
|
const query = options.version ? `?version=${options.version}` : "";
|
|
90
246
|
const result = await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}${query}`);
|
|
91
247
|
return result.value;
|
|
@@ -112,16 +268,10 @@ class ForgeZero {
|
|
|
112
268
|
return result.entries;
|
|
113
269
|
}
|
|
114
270
|
async set(name, value) {
|
|
115
|
-
|
|
116
|
-
method: "POST",
|
|
117
|
-
body: JSON.stringify({ name, value })
|
|
118
|
-
});
|
|
119
|
-
return result.version;
|
|
271
|
+
throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot write secrets; use the authenticated platform vault UI.");
|
|
120
272
|
}
|
|
121
273
|
async remove(name) {
|
|
122
|
-
|
|
123
|
-
method: "DELETE"
|
|
124
|
-
});
|
|
274
|
+
throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot delete secrets; use the authenticated platform vault UI.");
|
|
125
275
|
}
|
|
126
276
|
async* watch(options = {}) {
|
|
127
277
|
const interval = options.intervalMs ?? 15000;
|
|
@@ -146,7 +296,43 @@ function vaultCredentials(vault) {
|
|
|
146
296
|
get: (reference, field) => vault.get(`${reference}.${field}`)
|
|
147
297
|
};
|
|
148
298
|
}
|
|
149
|
-
|
|
299
|
+
function directCredentials(read) {
|
|
300
|
+
return { name: "platform-direct", get: read };
|
|
301
|
+
}
|
|
302
|
+
var readRuntimeCredential = (path) => {
|
|
303
|
+
if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function")
|
|
304
|
+
return;
|
|
305
|
+
try {
|
|
306
|
+
const fs = process.getBuiltinModule("node:fs");
|
|
307
|
+
const stat = fs.statSync(path);
|
|
308
|
+
if (!stat.isFile() || stat.size > 1024 * 1024)
|
|
309
|
+
return;
|
|
310
|
+
return fs.readFileSync(path, "utf8");
|
|
311
|
+
} catch {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
function systemdCredentials(options = {}) {
|
|
316
|
+
return {
|
|
317
|
+
name: "systemd",
|
|
318
|
+
async get(reference, field) {
|
|
319
|
+
const directory = options.directory ?? (typeof process !== "undefined" ? process.env?.CREDENTIALS_DIRECTORY : undefined);
|
|
320
|
+
if (!directory) {
|
|
321
|
+
throw new VaultError("CREDENTIAL_MISSING", "systemd did not provide CREDENTIALS_DIRECTORY to this unit.");
|
|
322
|
+
}
|
|
323
|
+
const name = (options.nameFor ?? ((ref, key) => `${ref}.${key}`))(reference, field);
|
|
324
|
+
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(name)) {
|
|
325
|
+
throw new VaultError("CREDENTIAL_NAME_INVALID", "A systemd credential name may contain only letters, digits, dot, underscore and hyphen.");
|
|
326
|
+
}
|
|
327
|
+
const value = (options.read ?? readRuntimeCredential)(`${directory}/${name}`);
|
|
328
|
+
if (value === undefined || value.length === 0) {
|
|
329
|
+
throw new VaultError("CREDENTIAL_MISSING", `The systemd credential ${name} is not available to this unit.`);
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
var VERSION = "0.1.2";
|
|
150
336
|
|
|
151
337
|
// src/schema.ts
|
|
152
338
|
function managedSchemas(vault, options = {}) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
|
|
3
3
|
"name": "@forgezero/vault",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -41,7 +41,15 @@
|
|
|
41
41
|
"typescript": "^5.6.0",
|
|
42
42
|
"@types/bun": "latest"
|
|
43
43
|
},
|
|
44
|
-
"
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@forgezero/runtime": "^0.1.2"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@noble/curves": "^2.2.0",
|
|
49
|
+
"@noble/post-quantum": "^0.6.1",
|
|
50
|
+
"@noble/hashes": "^2.2.0"
|
|
51
|
+
},
|
|
52
|
+
"description": "ForgeZero vault client. Credential discovery and versioned secrets through one stable API origin.",
|
|
45
53
|
"keywords": [
|
|
46
54
|
"secrets",
|
|
47
55
|
"vault",
|
|
@@ -55,7 +63,7 @@
|
|
|
55
63
|
"nextjs"
|
|
56
64
|
],
|
|
57
65
|
"license": "MIT",
|
|
58
|
-
"homepage": "https://forgezero.net/docs/vault-package",
|
|
66
|
+
"homepage": "https://www.forgezero.net/docs/vault-package",
|
|
59
67
|
"repository": {
|
|
60
68
|
"type": "git",
|
|
61
69
|
"url": "git+https://github.com/axxra/forgezero.git",
|