@forgezero/agent 0.1.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/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/cache.d.ts +124 -0
- package/dist/cache.test.d.ts +1 -0
- package/dist/fz-agent.js +392 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/pipeline.d.ts +108 -0
- package/dist/pipeline.js +81 -0
- package/dist/pipeline.test.d.ts +1 -0
- package/dist/provision.d.ts +117 -0
- package/dist/provision.js +128 -0
- package/dist/socket.d.ts +163 -0
- package/dist/socket.test.d.ts +1 -0
- package/dist/ssh-listen.d.ts +41 -0
- package/dist/ssh-listen.js +149 -0
- package/dist/ssh-listen.test.d.ts +1 -0
- package/dist/ssh-server.d.ts +84 -0
- package/dist/ssh-server.js +109 -0
- package/dist/ssh-server.test.d.ts +1 -0
- package/dist/subscribe.d.ts +101 -0
- package/dist/subscribe.js +121 -0
- package/dist/subscribe.test.d.ts +1 -0
- package/package.json +73 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ForgeZero
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @forgezero/agent
|
|
2
|
+
|
|
3
|
+
**`fz-agent` — so the application beside it holds no credential at all.**
|
|
4
|
+
|
|
5
|
+
The agent keeps a project-scoped copy of your vault in RAM and answers over a
|
|
6
|
+
unix socket. `@forgezero/vault` prefers that socket over an API key whenever it
|
|
7
|
+
exists, so an application on a machine running the agent reads its secrets
|
|
8
|
+
because of *where it is*, not because it holds something that could be stolen
|
|
9
|
+
from it.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add -g @forgezero/agent # or let `fz agent install` do it
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Most people never install this directly:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
fz agent install --apply # writes a hardened systemd unit
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What changes for an application
|
|
22
|
+
|
|
23
|
+
Nothing.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { ForgeZero } from '@forgezero/vault';
|
|
27
|
+
const fz = new ForgeZero({ project: 'altpilot', environment: 'production' });
|
|
28
|
+
await fz.get('STRIPE_KEY');
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
With no agent, that needs `FORGEZERO_API_KEY` in the environment. With the
|
|
32
|
+
agent, it does not — and the key that would have been in the environment does
|
|
33
|
+
not exist on the box.
|
|
34
|
+
|
|
35
|
+
## A local copy, not a cache
|
|
36
|
+
|
|
37
|
+
The agent holds the **whole assigned project + environment**, not just what has
|
|
38
|
+
already been read. That is the difference between surviving an outage and not: a
|
|
39
|
+
cache only has what you fetched, so a secret you have never read is exactly the
|
|
40
|
+
one you cannot get when the platform is unreachable.
|
|
41
|
+
|
|
42
|
+
The cost, stated rather than buried: a compromised guest exposes everything in
|
|
43
|
+
scope rather than only what was read. It is bounded by the scope the compute is
|
|
44
|
+
assigned to, and by SEV-SNP keeping the host out of guest memory.
|
|
45
|
+
|
|
46
|
+
**Never on disk.** A cache file would hand an attacker with filesystem access
|
|
47
|
+
every secret this guest has ever held — the exact artefact the design exists to
|
|
48
|
+
remove. A restart re-fetches.
|
|
49
|
+
|
|
50
|
+
**Invalidated by cursor, not by a timer.** A TTL alone means a rotated secret
|
|
51
|
+
keeps working for the length of the TTL, which is the window rotation exists to
|
|
52
|
+
close. The agent polls a change cursor and drops what moved.
|
|
53
|
+
|
|
54
|
+
**A stale read is refused, not served.** If sync has not succeeded within the
|
|
55
|
+
staleness bound, `get` fails rather than returning a value it can no longer
|
|
56
|
+
vouch for. An application that receives a revoked credential and succeeds with
|
|
57
|
+
it is worse off than one that receives an error — the error is visible.
|
|
58
|
+
|
|
59
|
+
## Two postures, decided by the hardware
|
|
60
|
+
|
|
61
|
+
| | |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `attested` | SEV-SNP guest. The credential is a hardware report, and the platform can refuse a node whose measurement is wrong. |
|
|
64
|
+
| `enrolled` | No SNP. The credential is the enrolment token plus a hybrid Ed25519 + ML-DSA-65 signature. |
|
|
65
|
+
|
|
66
|
+
Both are real. `enrolled` is still strictly better than an API key in the
|
|
67
|
+
application: the key never leaves the agent, rotation reaches every process, and
|
|
68
|
+
the socket is filesystem-scoped.
|
|
69
|
+
|
|
70
|
+
## The socket interface
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
identity who this node is
|
|
74
|
+
sign sign a request with the node key
|
|
75
|
+
attest a hardware report, or a refusal — never a fake
|
|
76
|
+
get one secret
|
|
77
|
+
sync what changed since a cursor
|
|
78
|
+
held what this guest is holding, by name only
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`attest` **refuses** when no source is configured rather than returning
|
|
82
|
+
something attestation-shaped. An operator who believes they have an attestation
|
|
83
|
+
when nothing produced one is worse off than one told plainly it is unavailable.
|
|
84
|
+
|
|
85
|
+
Full documentation: **https://forgezero.net/docs/agent**
|
|
86
|
+
|
|
87
|
+
## Licence
|
|
88
|
+
|
|
89
|
+
MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
|
|
90
|
+
deploys.
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent's secret cache, and why it never touches disk.
|
|
3
|
+
*
|
|
4
|
+
* An application on managed compute asks the agent for a value rather than the
|
|
5
|
+
* platform, so a rotation reaches every process on the box without any of them
|
|
6
|
+
* holding a credential to fetch it with. That only helps if the cache is
|
|
7
|
+
* cheaper than the call it replaces AND does not create the very artefact the
|
|
8
|
+
* design exists to remove.
|
|
9
|
+
*
|
|
10
|
+
* ## Memory only, deliberately
|
|
11
|
+
*
|
|
12
|
+
* Writing the cache to disk would give an attacker with filesystem access every
|
|
13
|
+
* secret this guest has ever read — which is precisely the file that does not
|
|
14
|
+
* exist today, and the entire reason a node holds a signing key instead of a
|
|
15
|
+
* `.env`. A restart re-fetches. That costs one round trip and removes a class of
|
|
16
|
+
* compromise; there is no version of "just persist it, it is encrypted" that
|
|
17
|
+
* survives the key also being on the box.
|
|
18
|
+
*
|
|
19
|
+
* ## Invalidation is driven by the cursor, not by a timer
|
|
20
|
+
*
|
|
21
|
+
* A TTL alone means a rotated secret keeps working for the length of the TTL —
|
|
22
|
+
* which is exactly the window rotation exists to close. `sync()` polls the
|
|
23
|
+
* platform's `/changes` cursor and drops what moved, so a rotation propagates at
|
|
24
|
+
* the poll interval regardless of TTL. The TTL is the backstop for the case
|
|
25
|
+
* where sync itself has stopped, and it is short for that reason.
|
|
26
|
+
*
|
|
27
|
+
* ## A stale read is refused, not served
|
|
28
|
+
*
|
|
29
|
+
* If sync has not succeeded within the staleness bound, `get` refuses rather
|
|
30
|
+
* than serving a value it can no longer vouch for. An application that receives
|
|
31
|
+
* a revoked credential and succeeds with it is worse off than one that receives
|
|
32
|
+
* an error: the error is visible, the success is not.
|
|
33
|
+
*/
|
|
34
|
+
export declare class CacheError extends Error {
|
|
35
|
+
readonly code: 'STALE' | 'NOT_CACHED' | 'FETCH_FAILED';
|
|
36
|
+
constructor(code: 'STALE' | 'NOT_CACHED' | 'FETCH_FAILED', message: string);
|
|
37
|
+
}
|
|
38
|
+
export interface CacheOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Fetches one value from the platform. Injected so the cache holds no
|
|
41
|
+
* transport and a test needs no network.
|
|
42
|
+
*/
|
|
43
|
+
fetch(name: string): Promise<{
|
|
44
|
+
value: string;
|
|
45
|
+
version: number;
|
|
46
|
+
}>;
|
|
47
|
+
/**
|
|
48
|
+
* Names changed since a cursor, and the new cursor.
|
|
49
|
+
*
|
|
50
|
+
* `resync` means the caller's cursor is unusable — a restored backup or a
|
|
51
|
+
* moved clock — and everything is dropped rather than a gap being left
|
|
52
|
+
* exactly where a rotation was.
|
|
53
|
+
*/
|
|
54
|
+
changes(since: number): Promise<{
|
|
55
|
+
version: number;
|
|
56
|
+
changed: string[];
|
|
57
|
+
resync?: boolean;
|
|
58
|
+
}>;
|
|
59
|
+
/**
|
|
60
|
+
* Every name in the assigned scope. Present means REPLICA mode.
|
|
61
|
+
*
|
|
62
|
+
* Absent means the old behaviour — fetch on demand, hold what was asked for.
|
|
63
|
+
* Present means the agent pulls the whole project+environment at startup and
|
|
64
|
+
* on every resync, so an application reads with no round trip and keeps
|
|
65
|
+
* working while the platform is unreachable.
|
|
66
|
+
*
|
|
67
|
+
* That is a deliberate widening of what a compromised guest exposes: not
|
|
68
|
+
* "whatever this app read" but "everything in scope". It is bounded by the
|
|
69
|
+
* scope the compute is assigned and by SEV-SNP keeping the host out of guest
|
|
70
|
+
* memory, and it is the point of the agent — a local copy that survives the
|
|
71
|
+
* API being down is not a cache, and a cache that only has what you already
|
|
72
|
+
* fetched does not survive anything.
|
|
73
|
+
*/
|
|
74
|
+
list?(): Promise<readonly string[]>;
|
|
75
|
+
/** Backstop for a sync that has stopped. Short on purpose. */
|
|
76
|
+
ttlMs?: number;
|
|
77
|
+
/** Refuse to serve anything if sync has not succeeded within this. */
|
|
78
|
+
maxStaleMs?: number;
|
|
79
|
+
now?: () => number;
|
|
80
|
+
}
|
|
81
|
+
export declare function createSecretCache(options: CacheOptions): {
|
|
82
|
+
/** For an operator asking what this guest is holding. Names, never values. */
|
|
83
|
+
names: () => string[];
|
|
84
|
+
/** True once the whole scope is resident. False in on-demand mode. */
|
|
85
|
+
readonly replica: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Load the whole assigned scope into memory.
|
|
88
|
+
*
|
|
89
|
+
* Called at startup and again after a `resync`. A resync means the cursor
|
|
90
|
+
* is unusable, so the alternative is an empty cache that refills lazily —
|
|
91
|
+
* which is exactly the availability the replica exists to provide, lost at
|
|
92
|
+
* the moment something already went wrong.
|
|
93
|
+
*/
|
|
94
|
+
load: () => Promise<{
|
|
95
|
+
loaded: number;
|
|
96
|
+
failed: string[];
|
|
97
|
+
}>;
|
|
98
|
+
readonly cursor: number;
|
|
99
|
+
/**
|
|
100
|
+
* One value, from cache when it is fresh enough to vouch for.
|
|
101
|
+
*
|
|
102
|
+
* The staleness check runs BEFORE the cache lookup. Serving a cached value
|
|
103
|
+
* while sync is broken is the failure this guards, so checking after the
|
|
104
|
+
* lookup would mean the hit path — the common one — skipped the guard.
|
|
105
|
+
*/
|
|
106
|
+
get(name: string): Promise<string>;
|
|
107
|
+
/**
|
|
108
|
+
* Poll for changes and drop what moved.
|
|
109
|
+
*
|
|
110
|
+
* Returns what it invalidated so a caller can log a rotation actually
|
|
111
|
+
* arriving — "sync ran" and "sync did something" are different facts, and
|
|
112
|
+
* only the second one tells you rotation works.
|
|
113
|
+
*/
|
|
114
|
+
sync(): Promise<{
|
|
115
|
+
invalidated: string[];
|
|
116
|
+
cursor: number;
|
|
117
|
+
resync: boolean;
|
|
118
|
+
}>;
|
|
119
|
+
/** Drop everything. Used on revocation, and by tests. */
|
|
120
|
+
clear(): void;
|
|
121
|
+
/** For the journal: how long since sync last succeeded. */
|
|
122
|
+
staleForMs: () => number;
|
|
123
|
+
};
|
|
124
|
+
export type SecretCache = ReturnType<typeof createSecretCache>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/fz-agent.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
import { randomBytes } from "crypto";
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync as existsSync2, mkdirSync, chmodSync as chmodSync2 } from "fs";
|
|
7
|
+
import { dirname } from "path";
|
|
8
|
+
import { deriveKeysFromSeed } from "@forgezero/runtime/identity";
|
|
9
|
+
import { DEFAULT_SOCKET } from "@forgezero/vault";
|
|
10
|
+
|
|
11
|
+
// src/socket.ts
|
|
12
|
+
import { createServer } from "net";
|
|
13
|
+
import { chmodSync, existsSync, unlinkSync } from "fs";
|
|
14
|
+
import { signRequest } from "@forgezero/runtime/identity";
|
|
15
|
+
|
|
16
|
+
// src/pipeline.ts
|
|
17
|
+
class PipelineError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = "PipelineError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function redact(text, values) {
|
|
26
|
+
let out = text;
|
|
27
|
+
for (const value of [...values].sort((a, b) => b.length - a.length)) {
|
|
28
|
+
if (value.length < 8)
|
|
29
|
+
continue;
|
|
30
|
+
out = out.split(value).join("\u2022\u2022\u2022\u2022redacted\u2022\u2022\u2022\u2022");
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
async function runPipeline(options) {
|
|
35
|
+
const now = options.now ?? (() => Date.now());
|
|
36
|
+
const { pipeline } = options;
|
|
37
|
+
let assurance = "enrolled";
|
|
38
|
+
if (options.attest) {
|
|
39
|
+
try {
|
|
40
|
+
await options.attest();
|
|
41
|
+
assurance = "attested";
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
if (pipeline.requireAttestation) {
|
|
44
|
+
throw new PipelineError("ATTESTATION_FAILED", `${pipeline.name} requires attestation and this machine could not produce one: ${cause.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else if (pipeline.requireAttestation) {
|
|
48
|
+
throw new PipelineError("ATTESTATION_REQUIRED", `${pipeline.name} requires attestation. This agent has no attestation source, so it cannot run it.`);
|
|
49
|
+
}
|
|
50
|
+
const steps = [];
|
|
51
|
+
let failed = false;
|
|
52
|
+
for (const step of pipeline.steps) {
|
|
53
|
+
if (failed && !step.always) {
|
|
54
|
+
steps.push({ name: step.name, outcome: "skipped", exitCode: null, log: "", durationMs: 0 });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const env = {};
|
|
58
|
+
const values = [];
|
|
59
|
+
for (const name of step.secrets ?? []) {
|
|
60
|
+
try {
|
|
61
|
+
const value = await options.secret(name);
|
|
62
|
+
env[name] = value;
|
|
63
|
+
values.push(value);
|
|
64
|
+
} catch {
|
|
65
|
+
throw new PipelineError("SECRET_MISSING", `step "${step.name}" needs ${name}, which is not in this compute's scope.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const started = now();
|
|
69
|
+
let exitCode;
|
|
70
|
+
let output;
|
|
71
|
+
try {
|
|
72
|
+
const result = await options.exec({ command: step.run, env, timeoutMs: step.timeoutMs });
|
|
73
|
+
exitCode = result.exitCode;
|
|
74
|
+
output = result.output;
|
|
75
|
+
} catch (cause) {
|
|
76
|
+
exitCode = -1;
|
|
77
|
+
output = cause.message;
|
|
78
|
+
}
|
|
79
|
+
const outcome = exitCode === 0 ? "ok" : "failed";
|
|
80
|
+
if (outcome === "failed")
|
|
81
|
+
failed = true;
|
|
82
|
+
steps.push({
|
|
83
|
+
name: step.name,
|
|
84
|
+
outcome,
|
|
85
|
+
exitCode,
|
|
86
|
+
log: redact(output, values),
|
|
87
|
+
durationMs: now() - started
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return { pipeline: pipeline.name, ok: !failed, assurance, steps };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/socket.ts
|
|
94
|
+
var MAX_LINE_BYTES = 64 * 1024;
|
|
95
|
+
function handleRequest(options, request) {
|
|
96
|
+
switch (request?.op) {
|
|
97
|
+
case "identity":
|
|
98
|
+
return Promise.resolve({
|
|
99
|
+
ok: true,
|
|
100
|
+
op: "identity",
|
|
101
|
+
nodeKey: options.nodeKey,
|
|
102
|
+
publicKeys: {
|
|
103
|
+
publicKey: options.keys.ed25519.publicKey,
|
|
104
|
+
secretKey: "",
|
|
105
|
+
mlDsa: options.keys.mlDsa.publicKey
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
case "sign": {
|
|
109
|
+
if (typeof request.method !== "string" || typeof request.path !== "string") {
|
|
110
|
+
return Promise.resolve(refuse("BAD_REQUEST", "A signature needs a method and a path."));
|
|
111
|
+
}
|
|
112
|
+
return Promise.resolve({
|
|
113
|
+
ok: true,
|
|
114
|
+
op: "sign",
|
|
115
|
+
envelope: signRequest(options.keys, options.nodeKey, {
|
|
116
|
+
method: request.method,
|
|
117
|
+
path: request.path,
|
|
118
|
+
query: request.query ?? "",
|
|
119
|
+
body: request.body ?? ""
|
|
120
|
+
})
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
case "get": {
|
|
124
|
+
if (!options.cache) {
|
|
125
|
+
return Promise.resolve(refuse("NO_CACHE", "This agent holds no secrets. It signs; it does not serve values."));
|
|
126
|
+
}
|
|
127
|
+
if (typeof request.name !== "string" || !request.name) {
|
|
128
|
+
return Promise.resolve(refuse("BAD_REQUEST", "A read needs a name."));
|
|
129
|
+
}
|
|
130
|
+
return options.cache.get(request.name).then((value) => ({ ok: true, op: "get", value })).catch((cause) => refuse(cause.code ?? "READ_FAILED", cause instanceof Error ? cause.message : "Could not read that value."));
|
|
131
|
+
}
|
|
132
|
+
case "sync": {
|
|
133
|
+
if (!options.cache) {
|
|
134
|
+
return Promise.resolve(refuse("NO_CACHE", "This agent holds no secrets to synchronise."));
|
|
135
|
+
}
|
|
136
|
+
return options.cache.sync().then((result) => ({ ok: true, op: "sync", ...result })).catch((cause) => refuse("SYNC_FAILED", cause instanceof Error ? cause.message : "Synchronisation failed."));
|
|
137
|
+
}
|
|
138
|
+
case "held": {
|
|
139
|
+
if (!options.cache) {
|
|
140
|
+
return Promise.resolve({
|
|
141
|
+
ok: true,
|
|
142
|
+
op: "held",
|
|
143
|
+
names: [],
|
|
144
|
+
staleForMs: 0
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return Promise.resolve({
|
|
148
|
+
ok: true,
|
|
149
|
+
op: "held",
|
|
150
|
+
names: options.cache.names(),
|
|
151
|
+
staleForMs: options.cache.staleForMs()
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
case "run": {
|
|
155
|
+
if (!options.cache) {
|
|
156
|
+
return Promise.resolve(refuse("NO_SCOPE", "This agent holds no project scope, so it cannot run a pipeline."));
|
|
157
|
+
}
|
|
158
|
+
if (!options.exec) {
|
|
159
|
+
return Promise.resolve(refuse("PIPELINE_DISABLED", "This agent was not started with an executor, so it will not run pipelines."));
|
|
160
|
+
}
|
|
161
|
+
const cache = options.cache;
|
|
162
|
+
const attestation = options.attestation;
|
|
163
|
+
return runPipeline({
|
|
164
|
+
pipeline: request.pipeline,
|
|
165
|
+
secret: (name) => cache.get(name),
|
|
166
|
+
exec: options.exec,
|
|
167
|
+
attest: attestation ? async () => ({ report: await attestation.report(""), source: attestation.name }) : undefined
|
|
168
|
+
}).then((result) => ({ ok: true, op: "run", result })).catch((cause) => refuse(cause.code ?? "PIPELINE_FAILED", cause.message));
|
|
169
|
+
}
|
|
170
|
+
case "attest": {
|
|
171
|
+
if (!options.attestation) {
|
|
172
|
+
return Promise.resolve(refuse("ATTESTATION_UNAVAILABLE", "No attestation source is configured on this guest. Attestation is refused rather " + "than faked, because a caller that believes it verified one when nothing did is " + "worse off than one told plainly it is unavailable."));
|
|
173
|
+
}
|
|
174
|
+
const nonce = request.nonce ?? "";
|
|
175
|
+
if (!nonce) {
|
|
176
|
+
return Promise.resolve(refuse("NONCE_REQUIRED", "An attestation report needs a nonce."));
|
|
177
|
+
}
|
|
178
|
+
return options.attestation.report(nonce).then((report) => ({
|
|
179
|
+
ok: true,
|
|
180
|
+
op: "attest",
|
|
181
|
+
report,
|
|
182
|
+
source: options.attestation.name
|
|
183
|
+
})).catch((cause) => refuse("ATTESTATION_FAILED", cause instanceof Error ? cause.message : "Attestation failed."));
|
|
184
|
+
}
|
|
185
|
+
default:
|
|
186
|
+
return Promise.resolve(refuse("UNKNOWN_OP", `No such operation. The socket signs; it does not hand over keys.`));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
var refuse = (code, message) => ({
|
|
190
|
+
ok: false,
|
|
191
|
+
error: { code, message }
|
|
192
|
+
});
|
|
193
|
+
function startAgent(options) {
|
|
194
|
+
if (existsSync(options.socketPath))
|
|
195
|
+
unlinkSync(options.socketPath);
|
|
196
|
+
const server = createServer((socket) => {
|
|
197
|
+
let buffer = "";
|
|
198
|
+
socket.on("data", (chunk) => {
|
|
199
|
+
buffer += chunk.toString("utf8");
|
|
200
|
+
if (buffer.length > MAX_LINE_BYTES) {
|
|
201
|
+
socket.end(`${JSON.stringify(refuse("TOO_LARGE", "Request too large."))}
|
|
202
|
+
`);
|
|
203
|
+
buffer = "";
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
let newline = buffer.indexOf(`
|
|
207
|
+
`);
|
|
208
|
+
while (newline !== -1) {
|
|
209
|
+
const line = buffer.slice(0, newline);
|
|
210
|
+
buffer = buffer.slice(newline + 1);
|
|
211
|
+
respond(options, socket, line);
|
|
212
|
+
newline = buffer.indexOf(`
|
|
213
|
+
`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
socket.on("error", () => socket.destroy());
|
|
217
|
+
});
|
|
218
|
+
server.listen(options.socketPath, () => {
|
|
219
|
+
chmodSync(options.socketPath, 384);
|
|
220
|
+
});
|
|
221
|
+
return server;
|
|
222
|
+
}
|
|
223
|
+
async function respond(options, socket, line) {
|
|
224
|
+
if (!line.trim())
|
|
225
|
+
return;
|
|
226
|
+
let response;
|
|
227
|
+
try {
|
|
228
|
+
response = await handleRequest(options, JSON.parse(line));
|
|
229
|
+
} catch (cause) {
|
|
230
|
+
response = refuse("MALFORMED", cause instanceof Error ? cause.message : "Malformed request.");
|
|
231
|
+
}
|
|
232
|
+
options.record?.({
|
|
233
|
+
op: JSON.parse(safeOp(line)).op ?? "unknown",
|
|
234
|
+
outcome: response.ok ? "ok" : "refused",
|
|
235
|
+
detail: response.ok ? undefined : response.error.code
|
|
236
|
+
});
|
|
237
|
+
socket.write(`${JSON.stringify(response)}
|
|
238
|
+
`);
|
|
239
|
+
}
|
|
240
|
+
function safeOp(line) {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(line);
|
|
243
|
+
return JSON.stringify({ op: typeof parsed.op === "string" ? parsed.op : "unknown" });
|
|
244
|
+
} catch {
|
|
245
|
+
return JSON.stringify({ op: "unparseable" });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// src/cache.ts
|
|
249
|
+
class CacheError extends Error {
|
|
250
|
+
code;
|
|
251
|
+
constructor(code, message) {
|
|
252
|
+
super(message);
|
|
253
|
+
this.code = code;
|
|
254
|
+
this.name = "CacheError";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
var DEFAULT_TTL_MS = 60000;
|
|
258
|
+
var DEFAULT_MAX_STALE_MS = 300000;
|
|
259
|
+
function createSecretCache(options) {
|
|
260
|
+
const entries = new Map;
|
|
261
|
+
const now = options.now ?? (() => Date.now());
|
|
262
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
263
|
+
const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
|
|
264
|
+
let cursor = 0;
|
|
265
|
+
let replicated = false;
|
|
266
|
+
let lastSyncOkMs = now();
|
|
267
|
+
const loadScope = async () => {
|
|
268
|
+
if (!options.list)
|
|
269
|
+
return { loaded: 0, failed: [] };
|
|
270
|
+
const names = await options.list();
|
|
271
|
+
const failed = [];
|
|
272
|
+
let loaded = 0;
|
|
273
|
+
for (const name of names) {
|
|
274
|
+
try {
|
|
275
|
+
const result = await options.fetch(name);
|
|
276
|
+
entries.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
|
|
277
|
+
loaded += 1;
|
|
278
|
+
} catch {
|
|
279
|
+
failed.push(name);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
replicated = true;
|
|
283
|
+
return { loaded, failed };
|
|
284
|
+
};
|
|
285
|
+
return {
|
|
286
|
+
names: () => [...entries.keys()],
|
|
287
|
+
get replica() {
|
|
288
|
+
return replicated;
|
|
289
|
+
},
|
|
290
|
+
load: loadScope,
|
|
291
|
+
get cursor() {
|
|
292
|
+
return cursor;
|
|
293
|
+
},
|
|
294
|
+
async get(name) {
|
|
295
|
+
const staleFor = now() - lastSyncOkMs;
|
|
296
|
+
if (staleFor > maxStaleMs) {
|
|
297
|
+
throw new CacheError("STALE", `Synchronisation has not succeeded for ${Math.round(staleFor / 1000)}s, so this ` + "cache can no longer vouch for what it holds. Refusing rather than serving a value " + "that may already be revoked.");
|
|
298
|
+
}
|
|
299
|
+
const cached = entries.get(name);
|
|
300
|
+
if (cached && now() - cached.fetchedAtMs < ttlMs)
|
|
301
|
+
return cached.value;
|
|
302
|
+
let fetched;
|
|
303
|
+
try {
|
|
304
|
+
fetched = await options.fetch(name);
|
|
305
|
+
} catch (cause) {
|
|
306
|
+
if (cached)
|
|
307
|
+
return cached.value;
|
|
308
|
+
throw new CacheError("FETCH_FAILED", cause instanceof Error ? cause.message : `Could not fetch ${name}.`);
|
|
309
|
+
}
|
|
310
|
+
entries.set(name, { ...fetched, fetchedAtMs: now() });
|
|
311
|
+
return fetched.value;
|
|
312
|
+
},
|
|
313
|
+
async sync() {
|
|
314
|
+
const result = await options.changes(cursor);
|
|
315
|
+
if (result.resync) {
|
|
316
|
+
const dropped = [...entries.keys()];
|
|
317
|
+
entries.clear();
|
|
318
|
+
cursor = 0;
|
|
319
|
+
lastSyncOkMs = now();
|
|
320
|
+
if (options.list)
|
|
321
|
+
await loadScope();
|
|
322
|
+
return { invalidated: dropped, cursor: 0, resync: true };
|
|
323
|
+
}
|
|
324
|
+
const invalidated = [];
|
|
325
|
+
for (const name of result.changed) {
|
|
326
|
+
if (entries.delete(name))
|
|
327
|
+
invalidated.push(name);
|
|
328
|
+
}
|
|
329
|
+
cursor = result.version;
|
|
330
|
+
lastSyncOkMs = now();
|
|
331
|
+
return { invalidated, cursor, resync: false };
|
|
332
|
+
},
|
|
333
|
+
clear() {
|
|
334
|
+
entries.clear();
|
|
335
|
+
cursor = 0;
|
|
336
|
+
},
|
|
337
|
+
staleForMs: () => now() - lastSyncOkMs
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/index.ts
|
|
342
|
+
var VERSION = "0.1.0";
|
|
343
|
+
function loadOrCreateSeed(path) {
|
|
344
|
+
if (existsSync2(path)) {
|
|
345
|
+
const seed2 = new Uint8Array(Buffer.from(readFileSync(path, "utf8").trim(), "base64url"));
|
|
346
|
+
if (seed2.length < 32) {
|
|
347
|
+
throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
|
|
348
|
+
}
|
|
349
|
+
return seed2;
|
|
350
|
+
}
|
|
351
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
352
|
+
const seed = new Uint8Array(randomBytes(32));
|
|
353
|
+
writeFileSync(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
|
|
354
|
+
chmodSync2(path, 384);
|
|
355
|
+
return seed;
|
|
356
|
+
}
|
|
357
|
+
var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
|
|
358
|
+
var DEFAULT_SEED_PATH = "/var/lib/forgezero/node.seed";
|
|
359
|
+
function runAgent(config = {}) {
|
|
360
|
+
const seedPath = config.seedPath ?? DEFAULT_SEED_PATH;
|
|
361
|
+
const keys = deriveKeysFromSeed(loadOrCreateSeed(seedPath));
|
|
362
|
+
const nodeKey = config.nodeKey ?? keys.ed25519.publicKey;
|
|
363
|
+
const server = startAgent({
|
|
364
|
+
socketPath: config.socketPath ?? DEFAULT_SOCKET_PATH,
|
|
365
|
+
keys,
|
|
366
|
+
nodeKey,
|
|
367
|
+
attestation: config.attestation,
|
|
368
|
+
cache: config.cache,
|
|
369
|
+
record: config.record
|
|
370
|
+
});
|
|
371
|
+
return { server, keys, nodeKey };
|
|
372
|
+
}
|
|
373
|
+
if (import.meta.main) {
|
|
374
|
+
const { nodeKey } = runAgent({
|
|
375
|
+
socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET_PATH,
|
|
376
|
+
seedPath: process.env.FZ_SEED_PATH ?? DEFAULT_SEED_PATH,
|
|
377
|
+
nodeKey: process.env.FZ_NODE_KEY,
|
|
378
|
+
record: (entry) => console.log(`[agent] ${entry.op} ${entry.outcome}${entry.detail ? ` ${entry.detail}` : ""}`)
|
|
379
|
+
});
|
|
380
|
+
console.log(`[agent] ${VERSION} signing as ${nodeKey}`);
|
|
381
|
+
}
|
|
382
|
+
export {
|
|
383
|
+
startAgent,
|
|
384
|
+
runAgent,
|
|
385
|
+
loadOrCreateSeed,
|
|
386
|
+
handleRequest,
|
|
387
|
+
createSecretCache,
|
|
388
|
+
VERSION,
|
|
389
|
+
DEFAULT_SOCKET_PATH,
|
|
390
|
+
DEFAULT_SEED_PATH,
|
|
391
|
+
CacheError
|
|
392
|
+
};
|