@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/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
|
*
|
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
* places, and the stronger posture is the one requiring less configuration
|
|
17
17
|
* rather than more.
|
|
18
18
|
*
|
|
19
|
-
* ##
|
|
19
|
+
* ## Stable API origin
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* External clients always call one API origin. Node selection and failover are
|
|
22
|
+
* edge responsibilities; exposing node hostnames here would couple every SDK
|
|
23
|
+
* to platform topology and can route a tenant to a process that does not hold
|
|
24
|
+
* its realm seed. Managed applications continue to use the local agent socket
|
|
25
|
+
* and make no remote request at all.
|
|
25
26
|
*
|
|
26
27
|
* This package must not import `@forgezero/providers` either. `vaultCredentials`
|
|
27
28
|
* returns the shape structurally, because a dependency edge in either direction
|
|
@@ -34,7 +35,7 @@ export declare class VaultError extends Error {
|
|
|
34
35
|
export type CredentialMode = 'managed' | 'external';
|
|
35
36
|
export interface Credential {
|
|
36
37
|
mode: CredentialMode;
|
|
37
|
-
/** MANAGED only — the agent socket
|
|
38
|
+
/** MANAGED only — the agent socket serving the environment's RAM replica. */
|
|
38
39
|
socketPath?: string;
|
|
39
40
|
/** EXTERNAL only — a seed, never transmitted; a keypair derives from it. */
|
|
40
41
|
apiKey?: string;
|
|
@@ -44,7 +45,14 @@ export interface DiscoveryEnvironment {
|
|
|
44
45
|
socketPath?: string;
|
|
45
46
|
socketExists?: (path: string) => boolean;
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Inside the RuntimeDirectory created by the agent's systemd unit.
|
|
50
|
+
*
|
|
51
|
+
* The old path was `/run/forgezero.sock`, directly under `/run`. The hardened
|
|
52
|
+
* unit runs as an unprivileged user and only receives `/run/forgezero`, so a
|
|
53
|
+
* fresh service could never create the socket its client was looking for.
|
|
54
|
+
*/
|
|
55
|
+
export declare const DEFAULT_SOCKET = "/run/forgezero/vault.sock";
|
|
48
56
|
/**
|
|
49
57
|
* Find a credential, preferring the stronger posture.
|
|
50
58
|
*
|
|
@@ -53,60 +61,88 @@ export declare const DEFAULT_SOCKET = "/run/forgezero.sock";
|
|
|
53
61
|
* machine that holds nothing into one holding a signing seed.
|
|
54
62
|
*/
|
|
55
63
|
export declare function discover(environment?: DiscoveryEnvironment): Credential;
|
|
56
|
-
|
|
64
|
+
export interface SignRequest {
|
|
65
|
+
method: string;
|
|
66
|
+
path: string;
|
|
67
|
+
query: string;
|
|
68
|
+
body: string;
|
|
69
|
+
}
|
|
70
|
+
/** Hybrid-signs one exact HTTP request. */
|
|
57
71
|
export interface Signer {
|
|
58
72
|
readonly keyId: string;
|
|
59
|
-
sign(
|
|
73
|
+
sign(request: SignRequest): Promise<string>;
|
|
60
74
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
/** Derive both private halves locally; only the public key id crosses the wire. */
|
|
76
|
+
export declare function signerFromApiKey(secret: string): Signer;
|
|
77
|
+
export type AgentRequest = {
|
|
78
|
+
op: 'get';
|
|
79
|
+
name: string;
|
|
80
|
+
} | {
|
|
81
|
+
op: 'held';
|
|
82
|
+
} | {
|
|
83
|
+
op: 'sync';
|
|
84
|
+
};
|
|
85
|
+
export type AgentResponse = {
|
|
86
|
+
ok: true;
|
|
87
|
+
op: 'get';
|
|
88
|
+
value: string;
|
|
89
|
+
} | {
|
|
90
|
+
ok: true;
|
|
91
|
+
op: 'held';
|
|
92
|
+
names: string[];
|
|
93
|
+
staleForMs: number;
|
|
94
|
+
} | {
|
|
95
|
+
ok: true;
|
|
96
|
+
op: 'sync';
|
|
97
|
+
invalidated: string[];
|
|
98
|
+
cursor: number;
|
|
99
|
+
resync: boolean;
|
|
100
|
+
} | {
|
|
101
|
+
ok: false;
|
|
102
|
+
error: {
|
|
103
|
+
code: string;
|
|
104
|
+
message: string;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
export type AgentTransport = (socketPath: string, request: AgentRequest) => Promise<AgentResponse>;
|
|
73
108
|
export interface VaultOptions {
|
|
74
|
-
/**
|
|
75
|
-
|
|
109
|
+
/** Stable public API origin. Node topology is never exposed to the client. */
|
|
110
|
+
apiUrl?: string;
|
|
111
|
+
requestTimeoutMs?: number;
|
|
76
112
|
project?: string;
|
|
77
113
|
environment?: string;
|
|
78
114
|
fetch?: typeof globalThis.fetch;
|
|
79
115
|
signer?: Signer;
|
|
116
|
+
agentTransport?: AgentTransport;
|
|
80
117
|
credential?: Credential;
|
|
81
118
|
discovery?: DiscoveryEnvironment;
|
|
82
|
-
now?: () => number;
|
|
83
119
|
}
|
|
84
120
|
export interface EntryMeta {
|
|
85
121
|
name: string;
|
|
86
|
-
|
|
87
|
-
|
|
122
|
+
/** Present on remote API-key reads; the local RAM replica exposes names only. */
|
|
123
|
+
version?: number;
|
|
124
|
+
/** Present on remote API-key reads; the local RAM replica exposes names only. */
|
|
125
|
+
updatedAtTs?: number;
|
|
88
126
|
}
|
|
89
127
|
export interface Change {
|
|
90
128
|
name: string;
|
|
91
129
|
version: number;
|
|
92
130
|
}
|
|
93
|
-
export declare const
|
|
131
|
+
export declare const DEFAULT_API_URL = "https://api.forgezero.net";
|
|
132
|
+
export declare const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
|
94
133
|
export declare class ForgeZero {
|
|
95
134
|
readonly credential: Credential;
|
|
96
|
-
private
|
|
97
|
-
private readonly assignUrl;
|
|
135
|
+
private readonly apiUrl;
|
|
98
136
|
private readonly doFetch;
|
|
99
|
-
private readonly now;
|
|
100
137
|
private readonly signer?;
|
|
138
|
+
private readonly agentTransport;
|
|
139
|
+
private readonly requestTimeoutMs;
|
|
101
140
|
private readonly project;
|
|
102
141
|
private readonly environment;
|
|
103
142
|
constructor(options?: VaultOptions);
|
|
104
|
-
/** The node to talk to, asking the directory only when the lease expired. */
|
|
105
|
-
node(): Promise<string>;
|
|
106
|
-
/** Drop the lease so the next call asks the directory again. */
|
|
107
|
-
reassign(): void;
|
|
108
143
|
private scope;
|
|
109
144
|
private request;
|
|
145
|
+
private managedRequest;
|
|
110
146
|
/** One value, current version unless asked otherwise. */
|
|
111
147
|
get(name: string, options?: {
|
|
112
148
|
version?: number;
|
|
@@ -161,7 +197,10 @@ export declare class ForgeZero {
|
|
|
161
197
|
}[]>;
|
|
162
198
|
/** Names and metadata. Never values. */
|
|
163
199
|
list(): Promise<readonly EntryMeta[]>;
|
|
164
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* Application credentials are read-only. Secret writes require a human
|
|
202
|
+
* session, step-up proof and a named audit actor on the platform surface.
|
|
203
|
+
*/
|
|
165
204
|
set(name: string, value: string): Promise<number>;
|
|
166
205
|
remove(name: string): Promise<void>;
|
|
167
206
|
/**
|
|
@@ -200,4 +239,38 @@ export declare function vaultCredentials(vault: ForgeZero): {
|
|
|
200
239
|
readonly name: string;
|
|
201
240
|
get(reference: string, field: string): Promise<string>;
|
|
202
241
|
};
|
|
203
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Trusted platform code injects its realm-scoped reader here.
|
|
244
|
+
*
|
|
245
|
+
* The package does not import a database or a master-seed implementation: that
|
|
246
|
+
* would force every tenant application to depend on ForgeZero internals. The
|
|
247
|
+
* API already has the unlocked realm seed and supplies a function that opens
|
|
248
|
+
* exactly the requested provider field in-process, without calling itself over
|
|
249
|
+
* HTTP or replicating plaintext beside itself.
|
|
250
|
+
*/
|
|
251
|
+
export declare function directCredentials(read: (reference: string, field: string) => Promise<string>): {
|
|
252
|
+
readonly name: 'platform-direct';
|
|
253
|
+
get(reference: string, field: string): Promise<string>;
|
|
254
|
+
};
|
|
255
|
+
export interface SystemdCredentialOptions {
|
|
256
|
+
/** Defaults to the private tmpfs directory PID 1 gives this unit. */
|
|
257
|
+
directory?: string;
|
|
258
|
+
/** Map a provider reference/field to the credential name declared by the unit. */
|
|
259
|
+
nameFor?: (reference: string, field: string) => string;
|
|
260
|
+
/** Test/runtime injection; returns undefined when absent. */
|
|
261
|
+
read?: (path: string) => string | undefined;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Bootstrap/recovery credentials loaded by systemd into read-only tmpfs.
|
|
265
|
+
*
|
|
266
|
+
* Explicit rather than part of `discover()`: an application must never
|
|
267
|
+
* downgrade from an attested agent to whatever unrelated credential files its
|
|
268
|
+
* unit happens to expose. Callers compose this behind the direct vault source
|
|
269
|
+
* with `chainCredentials()` only for values deliberately declared as bootstrap
|
|
270
|
+
* roots.
|
|
271
|
+
*/
|
|
272
|
+
export declare function systemdCredentials(options?: SystemdCredentialOptions): {
|
|
273
|
+
readonly name: 'systemd';
|
|
274
|
+
get(reference: string, field: string): Promise<string>;
|
|
275
|
+
};
|
|
276
|
+
export declare const VERSION = "0.1.2";
|
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,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,15 +296,54 @@ 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
|
export {
|
|
151
337
|
vaultCredentials,
|
|
338
|
+
systemdCredentials,
|
|
339
|
+
signerFromApiKey,
|
|
152
340
|
discover,
|
|
341
|
+
directCredentials,
|
|
153
342
|
createVault,
|
|
154
343
|
VaultError,
|
|
155
344
|
VERSION,
|
|
156
|
-
REASSIGN_ON,
|
|
157
345
|
ForgeZero,
|
|
158
346
|
DEFAULT_SOCKET,
|
|
159
|
-
|
|
347
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
348
|
+
DEFAULT_API_URL
|
|
160
349
|
};
|