@forgezero/vault 0.1.0 → 0.1.1
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 +109 -13
- package/dist/index.js +246 -25
- package/dist/providers.js +241 -25
- package/dist/schema.js +241 -25
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
**Read your secrets at runtime instead of shipping them in a file.**
|
|
4
4
|
|
|
5
|
-
The client discovers its own credential and
|
|
6
|
-
to. There is no endpoint to configure
|
|
7
|
-
|
|
5
|
+
The client discovers its own credential and, for an API key, asks a directory
|
|
6
|
+
which node to talk to. There is no endpoint to configure. The same code runs on
|
|
7
|
+
a laptop and in production: outside managed compute the key is a local signing
|
|
8
|
+
seed; on managed compute there is no remote credential in the application.
|
|
8
9
|
|
|
9
10
|
This one talks to a ForgeZero vault, so it needs an account. The other three
|
|
10
11
|
packages do not.
|
|
@@ -19,7 +20,7 @@ import { ForgeZero } from '@forgezero/vault';
|
|
|
19
20
|
const fz = new ForgeZero({ project: 'altpilot', environment: 'production' });
|
|
20
21
|
|
|
21
22
|
const key = await fz.get('STRIPE_KEY');
|
|
22
|
-
const names = await fz.list(); // names
|
|
23
|
+
const names = await fz.list(); // names; remote reads also include metadata
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
## Envless
|
|
@@ -33,12 +34,12 @@ import '@forgezero/vault/env'; // refuses to run during a build
|
|
|
33
34
|
Rotation stops being a project. A new version is written, readers pick it up on
|
|
34
35
|
their next fetch, and nothing is redeployed.
|
|
35
36
|
|
|
36
|
-
## Two transports, and the socket wins
|
|
37
|
+
## Two application transports, and the socket wins
|
|
37
38
|
|
|
38
|
-
On a ForgeZero compute the client
|
|
39
|
-
|
|
40
|
-
API key over HTTPS. When both are present the socket wins,
|
|
41
|
-
|
|
39
|
+
On a ForgeZero compute the client reads the environment's in-memory replica over
|
|
40
|
+
a Unix socket held by the local agent. Anywhere else it derives a hybrid signer
|
|
41
|
+
from an API key and reads over HTTPS. When both are present the socket wins,
|
|
42
|
+
because the application then holds no reusable remote credential.
|
|
42
43
|
|
|
43
44
|
## The API key is a seed, not a password
|
|
44
45
|
|
|
@@ -55,14 +56,14 @@ An entry can declare fields the tenant never holds. Two custody modes:
|
|
|
55
56
|
exists in plaintext only inside a signing call.
|
|
56
57
|
- **supplied** — you generate it, the vault seals it.
|
|
57
58
|
|
|
58
|
-
Either way there is no call that returns a private key.
|
|
59
|
-
|
|
59
|
+
Either way there is no call that returns a private key. Custody operations stay
|
|
60
|
+
on the authenticated platform surface; the application credential is read-only.
|
|
60
61
|
|
|
61
62
|
## Subpaths
|
|
62
63
|
|
|
63
64
|
| import | what it is |
|
|
64
65
|
|---|---|
|
|
65
|
-
| `@forgezero/vault` | the client — get, list
|
|
66
|
+
| `@forgezero/vault` | the read-only application client — get, list and watch rotation |
|
|
66
67
|
| `/env` | envless: fill `process.env` at boot |
|
|
67
68
|
| `/config` | read `.fz/config.json` — project, environment, which secrets |
|
|
68
69
|
| `/schema` | where an entry's shape comes from: pulled from the platform, or local |
|
|
@@ -74,9 +75,9 @@ address or a public key; `sign()` gives you a signature.
|
|
|
74
75
|
help. `BLOCKED` — access was cut deliberately and lifts just as fast.
|
|
75
76
|
`NO_CREDENTIAL` — neither a socket nor an API key was found.
|
|
76
77
|
|
|
77
|
-
Full documentation: **https://forgezero.net/docs/vault-package**
|
|
78
|
+
Full documentation: **https://www.forgezero.net/docs/vault-package**
|
|
78
79
|
|
|
79
80
|
## Licence
|
|
80
81
|
|
|
81
|
-
MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
|
|
82
|
+
MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
|
|
82
83
|
deploys.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @forgezero/vault — the client.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* rotation via `watch`.
|
|
4
|
+
* One audited ForgeZero identity dependency. Credential discovery, current and
|
|
5
|
+
* versioned reads, and rotation via `watch`.
|
|
6
6
|
*
|
|
7
7
|
* ## Discovery, and why it matters more than it looks
|
|
8
8
|
*
|
|
9
|
-
* /run/forgezero.sock exists
|
|
10
|
-
*
|
|
9
|
+
* /run/forgezero/vault.sock exists → MANAGED: read the agent's RAM replica;
|
|
10
|
+
* the app holds no remote credential
|
|
11
11
|
* FORGEZERO_API_KEY present → EXTERNAL: derive a keypair locally, sign here
|
|
12
12
|
* neither → throw, naming BOTH so the fix is obvious
|
|
13
13
|
*
|
|
@@ -34,7 +34,7 @@ export declare class VaultError extends Error {
|
|
|
34
34
|
export type CredentialMode = 'managed' | 'external';
|
|
35
35
|
export interface Credential {
|
|
36
36
|
mode: CredentialMode;
|
|
37
|
-
/** MANAGED only — the agent socket
|
|
37
|
+
/** MANAGED only — the agent socket serving the environment's RAM replica. */
|
|
38
38
|
socketPath?: string;
|
|
39
39
|
/** EXTERNAL only — a seed, never transmitted; a keypair derives from it. */
|
|
40
40
|
apiKey?: string;
|
|
@@ -44,7 +44,14 @@ export interface DiscoveryEnvironment {
|
|
|
44
44
|
socketPath?: string;
|
|
45
45
|
socketExists?: (path: string) => boolean;
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Inside the RuntimeDirectory created by the agent's systemd unit.
|
|
49
|
+
*
|
|
50
|
+
* The old path was `/run/forgezero.sock`, directly under `/run`. The hardened
|
|
51
|
+
* unit runs as an unprivileged user and only receives `/run/forgezero`, so a
|
|
52
|
+
* fresh service could never create the socket its client was looking for.
|
|
53
|
+
*/
|
|
54
|
+
export declare const DEFAULT_SOCKET = "/run/forgezero/vault.sock";
|
|
48
55
|
/**
|
|
49
56
|
* Find a credential, preferring the stronger posture.
|
|
50
57
|
*
|
|
@@ -53,11 +60,50 @@ export declare const DEFAULT_SOCKET = "/run/forgezero.sock";
|
|
|
53
60
|
* machine that holds nothing into one holding a signing seed.
|
|
54
61
|
*/
|
|
55
62
|
export declare function discover(environment?: DiscoveryEnvironment): Credential;
|
|
56
|
-
|
|
63
|
+
export interface SignRequest {
|
|
64
|
+
method: string;
|
|
65
|
+
path: string;
|
|
66
|
+
query: string;
|
|
67
|
+
body: string;
|
|
68
|
+
}
|
|
69
|
+
/** Hybrid-signs one exact HTTP request. */
|
|
57
70
|
export interface Signer {
|
|
58
71
|
readonly keyId: string;
|
|
59
|
-
sign(
|
|
72
|
+
sign(request: SignRequest): Promise<string>;
|
|
60
73
|
}
|
|
74
|
+
/** Derive both private halves locally; only the public key id crosses the wire. */
|
|
75
|
+
export declare function signerFromApiKey(secret: string): Signer;
|
|
76
|
+
export type AgentRequest = {
|
|
77
|
+
op: 'get';
|
|
78
|
+
name: string;
|
|
79
|
+
} | {
|
|
80
|
+
op: 'held';
|
|
81
|
+
} | {
|
|
82
|
+
op: 'sync';
|
|
83
|
+
};
|
|
84
|
+
export type AgentResponse = {
|
|
85
|
+
ok: true;
|
|
86
|
+
op: 'get';
|
|
87
|
+
value: string;
|
|
88
|
+
} | {
|
|
89
|
+
ok: true;
|
|
90
|
+
op: 'held';
|
|
91
|
+
names: string[];
|
|
92
|
+
staleForMs: number;
|
|
93
|
+
} | {
|
|
94
|
+
ok: true;
|
|
95
|
+
op: 'sync';
|
|
96
|
+
invalidated: string[];
|
|
97
|
+
cursor: number;
|
|
98
|
+
resync: boolean;
|
|
99
|
+
} | {
|
|
100
|
+
ok: false;
|
|
101
|
+
error: {
|
|
102
|
+
code: string;
|
|
103
|
+
message: string;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
export type AgentTransport = (socketPath: string, request: AgentRequest) => Promise<AgentResponse>;
|
|
61
107
|
export interface Assignment {
|
|
62
108
|
node: string;
|
|
63
109
|
ttl: number;
|
|
@@ -73,24 +119,33 @@ export declare const REASSIGN_ON: Set<number>;
|
|
|
73
119
|
export interface VaultOptions {
|
|
74
120
|
/** Directory endpoint. Unauthenticated — a node hostname is not a secret. */
|
|
75
121
|
assignUrl?: string;
|
|
122
|
+
/** Override only for a private ForgeZero installation with its own node domain. */
|
|
123
|
+
nodeAllowed?: (hostname: string) => boolean;
|
|
124
|
+
requestTimeoutMs?: number;
|
|
76
125
|
project?: string;
|
|
77
126
|
environment?: string;
|
|
78
127
|
fetch?: typeof globalThis.fetch;
|
|
79
128
|
signer?: Signer;
|
|
129
|
+
agentTransport?: AgentTransport;
|
|
80
130
|
credential?: Credential;
|
|
81
131
|
discovery?: DiscoveryEnvironment;
|
|
82
132
|
now?: () => number;
|
|
83
133
|
}
|
|
84
134
|
export interface EntryMeta {
|
|
85
135
|
name: string;
|
|
86
|
-
|
|
87
|
-
|
|
136
|
+
/** Present on remote API-key reads; the local RAM replica exposes names only. */
|
|
137
|
+
version?: number;
|
|
138
|
+
/** Present on remote API-key reads; the local RAM replica exposes names only. */
|
|
139
|
+
updatedAtTs?: number;
|
|
88
140
|
}
|
|
89
141
|
export interface Change {
|
|
90
142
|
name: string;
|
|
91
143
|
version: number;
|
|
92
144
|
}
|
|
93
|
-
export declare const DEFAULT_ASSIGN_URL = "https://
|
|
145
|
+
export declare const DEFAULT_ASSIGN_URL = "https://assign.forgezero.net/v1/assign";
|
|
146
|
+
export declare const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
|
147
|
+
/** A compromised directory must not turn a signed secret read into an open relay. */
|
|
148
|
+
export declare const isForgeZeroNode: (hostname: string) => boolean;
|
|
94
149
|
export declare class ForgeZero {
|
|
95
150
|
readonly credential: Credential;
|
|
96
151
|
private assignment;
|
|
@@ -98,6 +153,9 @@ export declare class ForgeZero {
|
|
|
98
153
|
private readonly doFetch;
|
|
99
154
|
private readonly now;
|
|
100
155
|
private readonly signer?;
|
|
156
|
+
private readonly agentTransport;
|
|
157
|
+
private readonly nodeAllowed;
|
|
158
|
+
private readonly requestTimeoutMs;
|
|
101
159
|
private readonly project;
|
|
102
160
|
private readonly environment;
|
|
103
161
|
constructor(options?: VaultOptions);
|
|
@@ -107,6 +165,7 @@ export declare class ForgeZero {
|
|
|
107
165
|
reassign(): void;
|
|
108
166
|
private scope;
|
|
109
167
|
private request;
|
|
168
|
+
private managedRequest;
|
|
110
169
|
/** One value, current version unless asked otherwise. */
|
|
111
170
|
get(name: string, options?: {
|
|
112
171
|
version?: number;
|
|
@@ -161,7 +220,10 @@ export declare class ForgeZero {
|
|
|
161
220
|
}[]>;
|
|
162
221
|
/** Names and metadata. Never values. */
|
|
163
222
|
list(): Promise<readonly EntryMeta[]>;
|
|
164
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* Application credentials are read-only. Secret writes require a human
|
|
225
|
+
* session, step-up proof and a named audit actor on the platform surface.
|
|
226
|
+
*/
|
|
165
227
|
set(name: string, value: string): Promise<number>;
|
|
166
228
|
remove(name: string): Promise<void>;
|
|
167
229
|
/**
|
|
@@ -200,4 +262,38 @@ export declare function vaultCredentials(vault: ForgeZero): {
|
|
|
200
262
|
readonly name: string;
|
|
201
263
|
get(reference: string, field: string): Promise<string>;
|
|
202
264
|
};
|
|
203
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Trusted platform code injects its realm-scoped reader here.
|
|
267
|
+
*
|
|
268
|
+
* The package does not import a database or a master-seed implementation: that
|
|
269
|
+
* would force every tenant application to depend on ForgeZero internals. The
|
|
270
|
+
* API already has the unlocked realm seed and supplies a function that opens
|
|
271
|
+
* exactly the requested provider field in-process, without calling itself over
|
|
272
|
+
* HTTP or replicating plaintext beside itself.
|
|
273
|
+
*/
|
|
274
|
+
export declare function directCredentials(read: (reference: string, field: string) => Promise<string>): {
|
|
275
|
+
readonly name: 'platform-direct';
|
|
276
|
+
get(reference: string, field: string): Promise<string>;
|
|
277
|
+
};
|
|
278
|
+
export interface SystemdCredentialOptions {
|
|
279
|
+
/** Defaults to the private tmpfs directory PID 1 gives this unit. */
|
|
280
|
+
directory?: string;
|
|
281
|
+
/** Map a provider reference/field to the credential name declared by the unit. */
|
|
282
|
+
nameFor?: (reference: string, field: string) => string;
|
|
283
|
+
/** Test/runtime injection; returns undefined when absent. */
|
|
284
|
+
read?: (path: string) => string | undefined;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Bootstrap/recovery credentials loaded by systemd into read-only tmpfs.
|
|
288
|
+
*
|
|
289
|
+
* Explicit rather than part of `discover()`: an application must never
|
|
290
|
+
* downgrade from an attested agent to whatever unrelated credential files its
|
|
291
|
+
* unit happens to expose. Callers compose this behind the direct vault source
|
|
292
|
+
* with `chainCredentials()` only for values deliberately declared as bootstrap
|
|
293
|
+
* roots.
|
|
294
|
+
*/
|
|
295
|
+
export declare function systemdCredentials(options?: SystemdCredentialOptions): {
|
|
296
|
+
readonly name: 'systemd';
|
|
297
|
+
get(reference: string, field: string): Promise<string>;
|
|
298
|
+
};
|
|
299
|
+
export declare const VERSION = "0.1.1";
|
package/dist/index.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,19 +9,111 @@ 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
|
}
|
|
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
|
+
});
|
|
21
113
|
var REASSIGN_ON = new Set([410, 421, 502, 503, 504, 530]);
|
|
22
|
-
var DEFAULT_ASSIGN_URL = "https://
|
|
114
|
+
var DEFAULT_ASSIGN_URL = "https://assign.forgezero.net/v1/assign";
|
|
115
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
|
116
|
+
var isForgeZeroNode = (hostname) => /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+forgezero\.net$/.test(hostname);
|
|
23
117
|
|
|
24
118
|
class ForgeZero {
|
|
25
119
|
credential;
|
|
@@ -28,14 +122,24 @@ class ForgeZero {
|
|
|
28
122
|
doFetch;
|
|
29
123
|
now;
|
|
30
124
|
signer;
|
|
125
|
+
agentTransport;
|
|
126
|
+
nodeAllowed;
|
|
127
|
+
requestTimeoutMs;
|
|
31
128
|
project;
|
|
32
129
|
environment;
|
|
33
130
|
constructor(options = {}) {
|
|
34
|
-
|
|
131
|
+
const credential = options.credential ?? discover(options.discovery);
|
|
132
|
+
this.credential = credential.mode === "managed" && !credential.socketPath ? { ...credential, socketPath: DEFAULT_SOCKET } : credential;
|
|
35
133
|
this.assignUrl = options.assignUrl ?? DEFAULT_ASSIGN_URL;
|
|
36
134
|
this.doFetch = options.fetch ?? globalThis.fetch;
|
|
37
135
|
this.now = options.now ?? Date.now;
|
|
38
|
-
this.signer = options.signer;
|
|
136
|
+
this.signer = options.signer ?? (this.credential.mode === "external" ? signerFromApiKey(this.credential.apiKey) : undefined);
|
|
137
|
+
this.agentTransport = options.agentTransport ?? requestAgent;
|
|
138
|
+
this.nodeAllowed = options.nodeAllowed ?? isForgeZeroNode;
|
|
139
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
140
|
+
if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 100 || this.requestTimeoutMs > 120000) {
|
|
141
|
+
throw new VaultError("INVALID_TIMEOUT", "requestTimeoutMs must be between 100 and 120000 milliseconds.");
|
|
142
|
+
}
|
|
39
143
|
this.project = options.project ?? "default";
|
|
40
144
|
this.environment = options.environment ?? "production";
|
|
41
145
|
}
|
|
@@ -43,17 +147,28 @@ class ForgeZero {
|
|
|
43
147
|
const now = this.now();
|
|
44
148
|
if (this.assignment && this.assignment.expiresAt > now)
|
|
45
149
|
return this.assignment.node;
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
150
|
+
const assignmentUrl = new URL(this.assignUrl);
|
|
151
|
+
if (this.signer?.keyId && !assignmentUrl.searchParams.has("client")) {
|
|
152
|
+
assignmentUrl.searchParams.set("client", this.signer.keyId);
|
|
153
|
+
}
|
|
154
|
+
const response = await this.doFetch(assignmentUrl, {
|
|
155
|
+
method: "GET",
|
|
156
|
+
headers: { accept: "application/json" },
|
|
157
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
50
158
|
});
|
|
51
159
|
if (!response.ok)
|
|
52
160
|
throw new VaultError("NO_NODE", "No vault node is available right now.");
|
|
53
|
-
const payload = await response.json();
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
161
|
+
const payload = await response.json().catch(() => null);
|
|
162
|
+
const hostname = typeof payload?.node === "string" ? payload.node : payload?.node && typeof payload.node === "object" && ("hostname" in payload.node) ? payload.node.hostname : undefined;
|
|
163
|
+
if (typeof hostname !== "string" || !this.nodeAllowed(hostname)) {
|
|
164
|
+
throw new VaultError("UNTRUSTED_NODE", "The directory returned an untrusted vault node hostname.");
|
|
165
|
+
}
|
|
166
|
+
const ttl = payload?.ttl ?? 60;
|
|
167
|
+
if (typeof ttl !== "number" || !Number.isFinite(ttl) || ttl < 1 || ttl > 300) {
|
|
168
|
+
throw new VaultError("INVALID_ASSIGNMENT", "The directory returned an invalid vault-node lease.");
|
|
169
|
+
}
|
|
170
|
+
this.assignment = { node: hostname, ttl, expiresAt: now + ttl * 1000 };
|
|
171
|
+
return hostname;
|
|
57
172
|
}
|
|
58
173
|
reassign() {
|
|
59
174
|
this.assignment = undefined;
|
|
@@ -62,17 +177,29 @@ class ForgeZero {
|
|
|
62
177
|
return `${encodeURIComponent(this.project)}/${encodeURIComponent(this.environment)}`;
|
|
63
178
|
}
|
|
64
179
|
async request(path, init = {}, retried = false) {
|
|
180
|
+
if (this.credential.mode === "managed")
|
|
181
|
+
return this.managedRequest(path, init);
|
|
65
182
|
const node = await this.node();
|
|
66
183
|
const body = typeof init.body === "string" ? init.body : "";
|
|
184
|
+
const target = new URL(path, "https://vault.invalid");
|
|
67
185
|
const headers = {
|
|
68
186
|
"content-type": "application/json",
|
|
69
187
|
...init.headers
|
|
70
188
|
};
|
|
71
189
|
if (this.signer) {
|
|
72
190
|
headers["x-fz-key"] = this.signer.keyId;
|
|
73
|
-
headers["x-fz-signature"] = await this.signer.sign(
|
|
191
|
+
headers["x-fz-signature"] = await this.signer.sign({
|
|
192
|
+
method: init.method ?? "GET",
|
|
193
|
+
path: target.pathname,
|
|
194
|
+
query: target.search.replace(/^\?/, ""),
|
|
195
|
+
body
|
|
196
|
+
});
|
|
74
197
|
}
|
|
75
|
-
const response = await this.doFetch(`https://${node}${path}`, {
|
|
198
|
+
const response = await this.doFetch(`https://${node}${path}`, {
|
|
199
|
+
...init,
|
|
200
|
+
headers,
|
|
201
|
+
signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs)
|
|
202
|
+
});
|
|
76
203
|
if (REASSIGN_ON.has(response.status) && !retried) {
|
|
77
204
|
this.reassign();
|
|
78
205
|
return this.request(path, init, true);
|
|
@@ -85,7 +212,66 @@ class ForgeZero {
|
|
|
85
212
|
}
|
|
86
213
|
return payload;
|
|
87
214
|
}
|
|
215
|
+
async managedRequest(path, init) {
|
|
216
|
+
const socketPath = this.credential.socketPath;
|
|
217
|
+
const method = init.method ?? "GET";
|
|
218
|
+
const target = new URL(path, "https://vault.invalid");
|
|
219
|
+
const entry = /\/entries\/([^/]+)$/.exec(target.pathname);
|
|
220
|
+
let response;
|
|
221
|
+
if (method === "GET" && entry) {
|
|
222
|
+
response = await this.agentTransport(socketPath, {
|
|
223
|
+
op: "get",
|
|
224
|
+
name: decodeURIComponent(entry[1])
|
|
225
|
+
});
|
|
226
|
+
if (!response.ok)
|
|
227
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
228
|
+
if (response.op !== "get")
|
|
229
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
230
|
+
return { value: response.value };
|
|
231
|
+
}
|
|
232
|
+
if (method === "GET" && target.pathname.endsWith("/list")) {
|
|
233
|
+
response = await this.agentTransport(socketPath, { op: "held" });
|
|
234
|
+
if (!response.ok)
|
|
235
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
236
|
+
if (response.op !== "held")
|
|
237
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
238
|
+
return { entries: response.names.map((name) => ({ name })) };
|
|
239
|
+
}
|
|
240
|
+
if (method === "GET" && target.pathname.endsWith("/entries")) {
|
|
241
|
+
response = await this.agentTransport(socketPath, { op: "held" });
|
|
242
|
+
if (!response.ok)
|
|
243
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
244
|
+
if (response.op !== "held")
|
|
245
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
246
|
+
const values = {};
|
|
247
|
+
for (const name of response.names) {
|
|
248
|
+
const read = await this.agentTransport(socketPath, { op: "get", name });
|
|
249
|
+
if (!read.ok)
|
|
250
|
+
throw new VaultError(read.error.code, read.error.message);
|
|
251
|
+
if (read.op !== "get")
|
|
252
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
253
|
+
values[name] = read.value;
|
|
254
|
+
}
|
|
255
|
+
return { values };
|
|
256
|
+
}
|
|
257
|
+
if (method === "GET" && target.pathname.endsWith("/changes")) {
|
|
258
|
+
response = await this.agentTransport(socketPath, { op: "sync" });
|
|
259
|
+
if (!response.ok)
|
|
260
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
261
|
+
if (response.op !== "sync")
|
|
262
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
263
|
+
return {
|
|
264
|
+
version: response.cursor,
|
|
265
|
+
changed: response.invalidated.map((name) => ({ name, version: 0 })),
|
|
266
|
+
resync: response.resync
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
throw new VaultError("MANAGED_READ_ONLY", "This operation is not exposed to an application through the managed agent socket.");
|
|
270
|
+
}
|
|
88
271
|
async get(name, options = {}) {
|
|
272
|
+
if (this.credential.mode === "managed" && options.version !== undefined) {
|
|
273
|
+
throw new VaultError("MANAGED_CURRENT_ONLY", "The managed agent RAM replica serves the current value only; use an API key for a historical version.");
|
|
274
|
+
}
|
|
89
275
|
const query = options.version ? `?version=${options.version}` : "";
|
|
90
276
|
const result = await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}${query}`);
|
|
91
277
|
return result.value;
|
|
@@ -112,16 +298,10 @@ class ForgeZero {
|
|
|
112
298
|
return result.entries;
|
|
113
299
|
}
|
|
114
300
|
async set(name, value) {
|
|
115
|
-
|
|
116
|
-
method: "POST",
|
|
117
|
-
body: JSON.stringify({ name, value })
|
|
118
|
-
});
|
|
119
|
-
return result.version;
|
|
301
|
+
throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot write secrets; use the authenticated platform vault UI.");
|
|
120
302
|
}
|
|
121
303
|
async remove(name) {
|
|
122
|
-
|
|
123
|
-
method: "DELETE"
|
|
124
|
-
});
|
|
304
|
+
throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot delete secrets; use the authenticated platform vault UI.");
|
|
125
305
|
}
|
|
126
306
|
async* watch(options = {}) {
|
|
127
307
|
const interval = options.intervalMs ?? 15000;
|
|
@@ -146,15 +326,56 @@ function vaultCredentials(vault) {
|
|
|
146
326
|
get: (reference, field) => vault.get(`${reference}.${field}`)
|
|
147
327
|
};
|
|
148
328
|
}
|
|
149
|
-
|
|
329
|
+
function directCredentials(read) {
|
|
330
|
+
return { name: "platform-direct", get: read };
|
|
331
|
+
}
|
|
332
|
+
var readRuntimeCredential = (path) => {
|
|
333
|
+
if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function")
|
|
334
|
+
return;
|
|
335
|
+
try {
|
|
336
|
+
const fs = process.getBuiltinModule("node:fs");
|
|
337
|
+
const stat = fs.statSync(path);
|
|
338
|
+
if (!stat.isFile() || stat.size > 1024 * 1024)
|
|
339
|
+
return;
|
|
340
|
+
return fs.readFileSync(path, "utf8");
|
|
341
|
+
} catch {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
function systemdCredentials(options = {}) {
|
|
346
|
+
return {
|
|
347
|
+
name: "systemd",
|
|
348
|
+
async get(reference, field) {
|
|
349
|
+
const directory = options.directory ?? (typeof process !== "undefined" ? process.env?.CREDENTIALS_DIRECTORY : undefined);
|
|
350
|
+
if (!directory) {
|
|
351
|
+
throw new VaultError("CREDENTIAL_MISSING", "systemd did not provide CREDENTIALS_DIRECTORY to this unit.");
|
|
352
|
+
}
|
|
353
|
+
const name = (options.nameFor ?? ((ref, key) => `${ref}.${key}`))(reference, field);
|
|
354
|
+
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(name)) {
|
|
355
|
+
throw new VaultError("CREDENTIAL_NAME_INVALID", "A systemd credential name may contain only letters, digits, dot, underscore and hyphen.");
|
|
356
|
+
}
|
|
357
|
+
const value = (options.read ?? readRuntimeCredential)(`${directory}/${name}`);
|
|
358
|
+
if (value === undefined || value.length === 0) {
|
|
359
|
+
throw new VaultError("CREDENTIAL_MISSING", `The systemd credential ${name} is not available to this unit.`);
|
|
360
|
+
}
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
var VERSION = "0.1.1";
|
|
150
366
|
export {
|
|
151
367
|
vaultCredentials,
|
|
368
|
+
systemdCredentials,
|
|
369
|
+
signerFromApiKey,
|
|
370
|
+
isForgeZeroNode,
|
|
152
371
|
discover,
|
|
372
|
+
directCredentials,
|
|
153
373
|
createVault,
|
|
154
374
|
VaultError,
|
|
155
375
|
VERSION,
|
|
156
376
|
REASSIGN_ON,
|
|
157
377
|
ForgeZero,
|
|
158
378
|
DEFAULT_SOCKET,
|
|
379
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
159
380
|
DEFAULT_ASSIGN_URL
|
|
160
381
|
};
|