@tomato414941/foundation 0.15.0 → 0.22.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/envelope.mjs +57 -0
- package/package.json +5 -3
- package/runtime.mjs +176 -47
- package/webauthn.mjs +44 -0
package/envelope.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// How a secret is kept so that only those it was handed to can open it. Each secret has a key of its own (K);
|
|
4
|
+
// its bytes are sealed with K, and K is sealed once per recipient with that recipient's public key: an
|
|
5
|
+
// envelope. Whoever holds the private key opens the envelope and then the bytes; nobody else, and not the
|
|
6
|
+
// server, which keeps only sealed bytes and envelopes. A recipient's private key is itself kept sealed by
|
|
7
|
+
// a key that only one of its WebAuthn credentials yields (its PRF output), or in a file it keeps itself.
|
|
8
|
+
//
|
|
9
|
+
// Keys are X25519; sealing is AES-256-GCM; the envelope key comes from HKDF-SHA256 over the ephemeral
|
|
10
|
+
// agreement. Every form is bytes: iv (12) + tag (16) + ciphertext, with the ephemeral public key (32) in
|
|
11
|
+
// front for an envelope. A browser does the same with Web Crypto.
|
|
12
|
+
const SPKI = Buffer.from('302a300506032b656e032100', 'hex'), PKCS8 = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
13
|
+
const ENVELOPE_INFO = 'foundation-envelope', WRAP_INFO = 'foundation-key';
|
|
14
|
+
|
|
15
|
+
export function generateKey() {
|
|
16
|
+
const { privateKey, publicKey } = generateKeyPairSync('x25519');
|
|
17
|
+
return { privateKey: privateKey.export({ type: 'pkcs8', format: 'der' }).subarray(-32), publicKey: publicKey.export({ type: 'spki', format: 'der' }).subarray(-32) };
|
|
18
|
+
}
|
|
19
|
+
export const publicKeyOf = privateKey => createPublicKey(toPrivate(privateKey)).export({ type: 'spki', format: 'der' }).subarray(-32);
|
|
20
|
+
const toPublic = raw => createPublicKey({ key: Buffer.concat([SPKI, raw]), type: 'spki', format: 'der' });
|
|
21
|
+
const toPrivate = raw => createPrivateKey({ key: Buffer.concat([PKCS8, raw]), type: 'pkcs8', format: 'der' });
|
|
22
|
+
|
|
23
|
+
function sealWith(key, bytes, aad) {
|
|
24
|
+
const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
25
|
+
if (aad) cipher.setAAD(aad);
|
|
26
|
+
const content = Buffer.concat([cipher.update(bytes), cipher.final()]);
|
|
27
|
+
return Buffer.concat([iv, cipher.getAuthTag(), content]);
|
|
28
|
+
}
|
|
29
|
+
function openWith(key, sealed, aad) {
|
|
30
|
+
if (sealed.length < 28) throw new Error('sealed bytes are too short');
|
|
31
|
+
const decipher = createDecipheriv('aes-256-gcm', key, sealed.subarray(0, 12));
|
|
32
|
+
if (aad) decipher.setAAD(aad);
|
|
33
|
+
decipher.setAuthTag(sealed.subarray(12, 28));
|
|
34
|
+
return Buffer.concat([decipher.update(sealed.subarray(28)), decipher.final()]);
|
|
35
|
+
}
|
|
36
|
+
const agree = (privateKey, publicKey, info) => Buffer.from(hkdfSync('sha256', diffieHellman({ privateKey: toPrivate(privateKey), publicKey: toPublic(publicKey) }), '', info, 32));
|
|
37
|
+
|
|
38
|
+
// The secret's own key, and its bytes sealed with it.
|
|
39
|
+
export const newContentKey = () => randomBytes(32);
|
|
40
|
+
export const sealContent = (contentKey, bytes) => sealWith(contentKey, bytes);
|
|
41
|
+
export const openContent = (contentKey, sealed) => openWith(contentKey, sealed);
|
|
42
|
+
|
|
43
|
+
// K sealed for one recipient: an ephemeral key agrees with the recipient's, and the recipient's public key
|
|
44
|
+
// is bound in as associated data so an envelope opens only for whom it was made.
|
|
45
|
+
export function seal(contentKey, recipientPublicKey) {
|
|
46
|
+
const ephemeral = generateKey();
|
|
47
|
+
return Buffer.concat([ephemeral.publicKey, sealWith(agree(ephemeral.privateKey, recipientPublicKey, ENVELOPE_INFO), contentKey, recipientPublicKey)]);
|
|
48
|
+
}
|
|
49
|
+
export function open(envelope, privateKey) {
|
|
50
|
+
if (envelope.length < 32 + 28) throw new Error('envelope is too short');
|
|
51
|
+
return openWith(agree(privateKey, envelope.subarray(0, 32), ENVELOPE_INFO), envelope.subarray(32), publicKeyOf(privateKey));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A private key sealed with what a credential yields, so that credential alone unseals it.
|
|
55
|
+
const wrapKey = yielded => Buffer.from(hkdfSync('sha256', yielded, '', WRAP_INFO, 32));
|
|
56
|
+
export const wrap = (privateKey, yielded) => sealWith(wrapKey(yielded), privateKey);
|
|
57
|
+
export const unwrap = (wrapped, yielded) => openWith(wrapKey(yielded), wrapped);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomato414941/foundation",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Foundation CLI:
|
|
3
|
+
"version": "0.22.0",
|
|
4
|
+
"description": "Foundation CLI: give this machine a WebAuthn credential, and hand what is kept to a command without it passing through the agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=24"
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"runtime.mjs",
|
|
14
|
-
"env-name.mjs"
|
|
14
|
+
"env-name.mjs",
|
|
15
|
+
"webauthn.mjs",
|
|
16
|
+
"envelope.mjs"
|
|
15
17
|
],
|
|
16
18
|
"repository": {
|
|
17
19
|
"type": "git",
|
package/runtime.mjs
CHANGED
|
@@ -8,47 +8,58 @@ import { homedir, hostname, tmpdir } from 'node:os';
|
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
10
|
import { validEnvName } from './env-name.mjs';
|
|
11
|
+
import { createCredential, answer } from './webauthn.mjs';
|
|
12
|
+
import { newContentKey, sealContent, seal, open as openEnvelope, openContent, generateKey } from './envelope.mjs';
|
|
11
13
|
|
|
12
14
|
// This program does only what the agent running it cannot do for itself.
|
|
13
15
|
//
|
|
14
16
|
// Everything Foundation offers is plain HTTP, and an agent with the key can call it directly; a command
|
|
15
17
|
// wrapper around those calls would only narrow what the agent is allowed to think of. Two things are left:
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
+
// init say which server, and make this machine's WebAuthn credential: the machine becomes a principal there, of
|
|
19
|
+
// nobody's. Its private key has to exist as a private file before anything can be asked, and nothing prints it.
|
|
20
|
+
// join ask a person to make this machine their agent, so that it may act for them.
|
|
21
|
+
// token prove this machine with its credential and print a bearer token that lasts an hour, for calling the API.
|
|
18
22
|
// exec hand what is kept to a command, or keep a file it creates, without the bytes passing through
|
|
19
23
|
// the agent. If the agent fetched the values itself they would be in its context.
|
|
20
24
|
// There is also `api`, which is for people and for scripts rather than for agents: it attaches the key to a
|
|
21
25
|
// request and prints what comes back. One escape hatch, so that the API can grow without this program growing
|
|
22
26
|
// a verb for every endpoint, and without deciding for an agent how it ought to use any of them.
|
|
23
|
-
// The key file:
|
|
24
|
-
// machine
|
|
27
|
+
// The key file: this machine's WebAuthn credential - its id, whose it is, and its private key - kept private, and
|
|
28
|
+
// the machine's own key for what is sealed for it (envelope.mjs). Both private halves are made here and never leave;
|
|
29
|
+
// Foundation keeps only the public halves. A file may instead hold an access key Foundation issued - as handed out on the
|
|
30
|
+
// web for a machine, or given to a lent machine - and, unless it is a lent machine's, it is replaced with a credential the
|
|
31
|
+
// first time it is used.
|
|
25
32
|
async function readKey(path, { missingOk = false } = {}) {
|
|
26
33
|
let handle;
|
|
27
34
|
try {
|
|
28
35
|
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
29
36
|
const info = await handle.stat();
|
|
30
|
-
if (!info.isFile() || info.size >
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
|
|
37
|
+
if (!info.isFile() || info.size > 4096 || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('Runtime key file must be owned by the current user and private (mode 600).');
|
|
38
|
+
const content = (await handle.readFile('utf8')).trim();
|
|
39
|
+
if (/^fdn_[A-Za-z0-9_-]{43}$/.test(content)) return { token: content };
|
|
40
|
+
let credential, own;
|
|
41
|
+
try { ({ webauthn_credential: credential, key: own } = JSON.parse(content)); } catch {}
|
|
42
|
+
if (typeof credential?.id !== 'string' || typeof credential.user !== 'string' || credential.private_key?.kty !== 'EC') throw new Error('Invalid runtime key file.');
|
|
43
|
+
if (own !== undefined && !/^[A-Za-z0-9_-]{43}$/.test(own?.private_key ?? '')) throw new Error('Invalid runtime key file.');
|
|
44
|
+
return { credential, own };
|
|
34
45
|
} catch (error) {
|
|
35
|
-
if (error.code === 'ENOENT') { if (missingOk) return null; throw new Error('No key yet. Run: foundation
|
|
46
|
+
if (error.code === 'ENOENT') { if (missingOk) return null; throw new Error('No key yet. Run: foundation init'); }
|
|
36
47
|
if (error.code === 'ELOOP') throw new Error('Runtime key file must not be a symbolic link.');
|
|
37
48
|
throw error;
|
|
38
49
|
} finally { await handle?.close(); }
|
|
39
50
|
}
|
|
40
|
-
async function writeKey(path,
|
|
51
|
+
async function writeKey(path, content, privateDirectory) {
|
|
41
52
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
42
53
|
if (privateDirectory) {
|
|
43
54
|
const directory = await stat(dirname(path));
|
|
44
55
|
if ((directory.mode & 0o077) || (process.getuid && directory.uid !== process.getuid())) throw new Error('Foundation key directory must be owned by the current user and private (mode 700).');
|
|
45
56
|
}
|
|
46
57
|
const created = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
|
|
47
|
-
try { await created.writeFile(
|
|
58
|
+
try { await created.writeFile(content + '\n'); await created.sync(); } finally { await created.close(); }
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
const VERSION = createRequire(import.meta.url)('./package.json').version;
|
|
51
|
-
// Which server this machine talks to is a setting, not part of the program: `
|
|
62
|
+
// Which server this machine talks to is a setting, not part of the program: `init <url>` writes it here,
|
|
52
63
|
// and FOUNDATION_URL, when set, wins for that one run.
|
|
53
64
|
const configPath = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'foundation', 'config.json');
|
|
54
65
|
async function savedUrl() {
|
|
@@ -63,7 +74,7 @@ async function saveUrl(origin) {
|
|
|
63
74
|
}
|
|
64
75
|
function serverUrl(value) {
|
|
65
76
|
let url;
|
|
66
|
-
try { url = new URL(value); } catch { throw new Error('No Foundation server yet. Run: foundation
|
|
77
|
+
try { url = new URL(value); } catch { throw new Error('No Foundation server yet. Run: foundation init <url>'); }
|
|
67
78
|
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname))) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('The Foundation URL must be an HTTPS origin (HTTP is allowed only on localhost).');
|
|
68
79
|
return url;
|
|
69
80
|
}
|
|
@@ -99,23 +110,27 @@ async function outputBytes(path) {
|
|
|
99
110
|
const HELP = `Usage: foundation <command> [options]
|
|
100
111
|
|
|
101
112
|
Commands:
|
|
102
|
-
|
|
113
|
+
init [<url>] [--name <name>] Make this machine's credential: it becomes a principal of its own.
|
|
103
114
|
With <url>, remember that Foundation server for later commands.
|
|
115
|
+
join Ask a person to make this machine their agent.
|
|
116
|
+
token Print a bearer token for the API, valid for an hour.
|
|
104
117
|
api <METHOD> </path> [--json <body>] [--from <file>] [--type <media-type>]
|
|
105
|
-
Send one request to the Foundation API
|
|
118
|
+
Send one request to the Foundation API as this machine.
|
|
106
119
|
exec <ENV>=<name> [...] -- <command> [args...]
|
|
107
120
|
Run a command with saved values in its environment.
|
|
108
121
|
exec --inputs '<json>' -- <command> The same, with files, structured inputs, or a connection for a service by id.
|
|
109
122
|
exec --output '<json>' -- <command> Also save a file the command writes.
|
|
123
|
+
keep <name> --from <file> Save a file as a secret, sealed here for whoever may open it.
|
|
124
|
+
read <name> Print a secret this machine was handed an envelope for.
|
|
110
125
|
version Print the version.
|
|
111
126
|
|
|
112
127
|
API specification:
|
|
113
128
|
foundation api GET /openapi.json Read the server's OpenAPI specification; no key required.
|
|
114
129
|
|
|
115
130
|
Environment:
|
|
116
|
-
FOUNDATION_URL The server for this run (otherwise the one saved by
|
|
131
|
+
FOUNDATION_URL The server for this run (otherwise the one saved by init).
|
|
117
132
|
FOUNDATION_AGENT Your name, such as claude or codex; gives each agent its own key file.
|
|
118
|
-
FOUNDATION_RUNTIME_KEY_FILE Where the key file is.
|
|
133
|
+
FOUNDATION_RUNTIME_KEY_FILE Where the key file (this machine's credential) is.
|
|
119
134
|
`;
|
|
120
135
|
|
|
121
136
|
async function main() {
|
|
@@ -157,12 +172,14 @@ async function main() {
|
|
|
157
172
|
if (names.some(item => item.as === output.as)) throw new Error('Output needs a different environment variable from every input.');
|
|
158
173
|
}
|
|
159
174
|
}
|
|
160
|
-
let call,
|
|
161
|
-
if (action === '
|
|
175
|
+
let call, serverGiven, name;
|
|
176
|
+
if (action === 'init') {
|
|
162
177
|
const parsed = parseArgs({ args, options: { name: { type: 'string' } }, strict: true, allowPositionals: true });
|
|
163
|
-
if (parsed.positionals.length > 1) throw new Error('Usage:
|
|
164
|
-
|
|
178
|
+
if (parsed.positionals.length > 1) throw new Error('Usage: init [<url>] [--name <name>]');
|
|
179
|
+
serverGiven = parsed.positionals[0];
|
|
165
180
|
name = parsed.values.name;
|
|
181
|
+
} else if (action === 'join') {
|
|
182
|
+
if (args.length) throw new Error('Usage: join');
|
|
166
183
|
} else if (action === 'api') {
|
|
167
184
|
const parsed = parseArgs({ args, options: { json: { type: 'string' }, from: { type: 'string' }, type: { type: 'string' } }, strict: true, allowPositionals: true });
|
|
168
185
|
if (parsed.positionals.length !== 2 || !/^(GET|POST|PUT|DELETE|PATCH)$/.test(parsed.positionals[0]) || !parsed.positionals[1].startsWith('/')) {
|
|
@@ -174,13 +191,34 @@ async function main() {
|
|
|
174
191
|
const content = parsed.values.from !== undefined ? await readFile(parsed.values.from) : parsed.values.json !== undefined ? Buffer.from(parsed.values.json) : method === 'GET' ? undefined : Buffer.from('{}');
|
|
175
192
|
call = { method, target: parsed.positionals[1], body: content,
|
|
176
193
|
type: parsed.values.type || (parsed.values.from !== undefined ? 'application/octet-stream' : 'application/json') };
|
|
194
|
+
} else if (action === 'keep') {
|
|
195
|
+
const parsed = parseArgs({ args, options: { from: { type: 'string' } }, strict: true, allowPositionals: true });
|
|
196
|
+
if (parsed.positionals.length !== 1 || parsed.values.from === undefined) throw new Error('Usage: keep <name> --from <file>');
|
|
197
|
+
const content = await readFile(parsed.values.from);
|
|
198
|
+
if (!content.length || content.length > 1024 * 1024) throw new Error('A secret must contain 1 byte to 1MB.');
|
|
199
|
+
call = { name: parsed.positionals[0], body: content };
|
|
200
|
+
} else if (action === 'read') {
|
|
201
|
+
if (args.length !== 1) throw new Error('Usage: read <name>');
|
|
202
|
+
call = { name: args[0] };
|
|
203
|
+
} else if (action === 'token') {
|
|
204
|
+
if (args.length) throw new Error('Usage: token');
|
|
177
205
|
} else if (!(action === 'exec' && (names.length || output) && command.length)) {
|
|
178
|
-
throw new Error('Usage:
|
|
206
|
+
throw new Error('Usage: init [<url>] [--name <name>] | join | token | exec [<ENV>=<name> ... | --inputs <json>] [--output <json>] -- <command> [args...] | keep <name> --from <file> | read <name> | api <method> </path> [--json <body>] [--from <file>]');
|
|
179
207
|
}
|
|
180
|
-
const url = serverUrl(
|
|
208
|
+
const url = serverUrl(serverGiven ?? configured);
|
|
181
209
|
const keyPath = process.env.FOUNDATION_RUNTIME_KEY_FILE || join(homedir(), '.local', 'state', 'foundation', createHash('sha256').update(url.origin).digest('hex').slice(0, 24) + (agentName ? '-' + agentName.toLowerCase().replace(/[^a-z0-9]+/g, '-') : '') + '.key');
|
|
182
210
|
const publicSpec = action === 'api' && call.method === 'GET' && call.target === '/openapi.json';
|
|
183
|
-
let
|
|
211
|
+
let key = publicSpec ? null : await readKey(keyPath, { missingOk: action === 'init' }), token = key?.token ?? null;
|
|
212
|
+
// The credential proves this machine for an hour at a time: the challenge is answered for the server actually reached.
|
|
213
|
+
async function prove() {
|
|
214
|
+
const begin = await fetch(url.origin + '/v1/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'webauthn' }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
215
|
+
const { options } = await begin.json();
|
|
216
|
+
const response = await fetch(url.origin + '/v1/session', { method: 'PUT', headers: { 'content-type': 'application/json' },
|
|
217
|
+
body: JSON.stringify({ kind: 'webauthn', credential: answer(options, key.credential, url.origin), session: 'token' }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
218
|
+
const proven = await response.json();
|
|
219
|
+
if (!response.ok || typeof proven.token !== 'string') throw new Error('Foundation did not accept this machine\'s credential (' + response.status + ', ' + (proven.error?.code || 'unknown') + ').');
|
|
220
|
+
return proven.token;
|
|
221
|
+
}
|
|
184
222
|
async function send(target, payload, { accept, method = 'POST', type = 'application/json' } = {}) {
|
|
185
223
|
const response = await fetch(url.origin + target, { method, headers: { authorization: 'Bearer ' + token, ...(payload === undefined ? {} : { 'content-type': type }) },
|
|
186
224
|
body: payload === undefined ? undefined : type === 'application/json' ? JSON.stringify(payload) : payload, redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
@@ -188,7 +226,35 @@ async function main() {
|
|
|
188
226
|
if (!response.ok && !accept?.(data)) throw new Error('Foundation request failed (' + response.status + ', ' + (data.error?.code || 'unknown') + '). ' + (data.error?.message || 'Check the connection and runtime permission.'));
|
|
189
227
|
return data;
|
|
190
228
|
}
|
|
191
|
-
//
|
|
229
|
+
// A key file holding an issued access key: register a WebAuthn credential with it, and keep the credential instead.
|
|
230
|
+
// A lent machine's key stays as it is; it ends with the machine.
|
|
231
|
+
async function upgrade(label) {
|
|
232
|
+
const me = await send('/v1/principals/me', undefined, { method: 'GET' });
|
|
233
|
+
if (me.key?.environment) return;
|
|
234
|
+
const { options } = await send('/v1/principals/' + encodeURIComponent(me.principal.id) + '/credentials', { kind: 'webauthn' });
|
|
235
|
+
const made = createCredential(options, url.origin);
|
|
236
|
+
await send('/v1/principals/' + encodeURIComponent(me.principal.id) + '/credentials', { kind: 'webauthn', name: label, credential: made.response }, { method: 'PUT' });
|
|
237
|
+
await writeKey(keyPath, JSON.stringify({ webauthn_credential: made.credential }), !process.env.FOUNDATION_RUNTIME_KEY_FILE);
|
|
238
|
+
key = { credential: made.credential };
|
|
239
|
+
}
|
|
240
|
+
// This machine's own key, made and published once it is a principal here: what is sealed for it opens with this.
|
|
241
|
+
// A key published elsewhere for this principal stays as it is; then nothing sealed for it opens here.
|
|
242
|
+
async function publishKey() {
|
|
243
|
+
if (key.own) return;
|
|
244
|
+
const made = generateKey();
|
|
245
|
+
const published = await send('/v1/principals/me/key', { public_key: made.publicKey.toString('base64url') }, { method: 'PUT', accept: data => data.error?.code === 'key_exists' });
|
|
246
|
+
if (published.error) return;
|
|
247
|
+
key.own = { private_key: made.privateKey.toString('base64url') };
|
|
248
|
+
await writeKey(keyPath, JSON.stringify({ webauthn_credential: key.credential, key: key.own }), !process.env.FOUNDATION_RUNTIME_KEY_FILE);
|
|
249
|
+
}
|
|
250
|
+
if (key?.token && action !== 'init') { await upgrade(hostname() + ' の ' + (agentName || 'AI')); }
|
|
251
|
+
// A credential this server no longer knows leaves initialising again; anything else needs it.
|
|
252
|
+
if (key?.credential) {
|
|
253
|
+
try { token = await prove(); await publishKey(); }
|
|
254
|
+
catch (error) { if (action !== 'init') throw error; key = null; token = null; }
|
|
255
|
+
}
|
|
256
|
+
if (action === 'token') { console.log(token); return; }
|
|
257
|
+
// One request, as this machine, and the answer printed as it came. Nothing here knows the endpoints.
|
|
192
258
|
if (action === 'api') {
|
|
193
259
|
if (!publicSpec && !/[?&]as=/.test(call.target)) {
|
|
194
260
|
const me = await send('/v1/principals/me', undefined, { method: 'GET', accept: () => true });
|
|
@@ -202,25 +268,52 @@ async function main() {
|
|
|
202
268
|
if (!response.ok) process.exitCode = 1;
|
|
203
269
|
return;
|
|
204
270
|
}
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
if (action === '
|
|
208
|
-
// A key someone already accepted has nothing to ask; connecting again only changes which server is remembered.
|
|
209
|
-
// No key, or one this server does not know: become a principal there first, and keep what it issues.
|
|
271
|
+
// Becoming a principal here: a key this server knows has nothing to make; initialising again only changes which
|
|
272
|
+
// server is remembered. The key itself is never printed: it stays in the file.
|
|
273
|
+
if (action === 'init') {
|
|
210
274
|
const wanted = name ?? hostname() + ' の ' + (agentName || 'AI');
|
|
211
275
|
let me = null;
|
|
212
276
|
if (token) me = await send('/v1/principals/me', undefined, { method: 'GET', accept: data => data.error?.code === 'not_approved' });
|
|
213
277
|
if (!token || me?.error) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
278
|
+
// No key, or one this server does not know: a WebAuthn credential made here, proven by nobody, makes this machine a
|
|
279
|
+
// principal there, of nobody's - the same call a browser's passkey makes.
|
|
280
|
+
key = null; me = null;
|
|
281
|
+
const post = (method, payload) => fetch(url.origin + '/v1/principals', { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
282
|
+
const begun = await post('POST', { kind: 'webauthn', name: wanted });
|
|
283
|
+
const { options, error } = await begun.json();
|
|
284
|
+
if (!begun.ok) throw new Error('Foundation did not begin a credential (' + begun.status + ', ' + (error?.code || 'unknown') + ').');
|
|
285
|
+
const made = createCredential(options, url.origin);
|
|
286
|
+
const done = await post('PUT', { kind: 'webauthn', name: wanted, principal_name: wanted, credential: made.response, session: 'token' });
|
|
287
|
+
const became = await done.json();
|
|
288
|
+
if (!done.ok || typeof became.token !== 'string') throw new Error('Foundation did not accept this machine\'s credential (' + done.status + ', ' + (became.error?.code || 'unknown') + ').');
|
|
289
|
+
key = { credential: made.credential }; token = became.token;
|
|
290
|
+
await writeKey(keyPath, JSON.stringify({ webauthn_credential: key.credential }), !process.env.FOUNDATION_RUNTIME_KEY_FILE);
|
|
291
|
+
await publishKey();
|
|
292
|
+
} else if (key?.token) { await upgrade(wanted); token = await prove(); await publishKey(); }
|
|
293
|
+
if (serverGiven !== undefined) await saveUrl(url.origin);
|
|
294
|
+
if (!me) me = await send('/v1/principals/me', undefined, { method: 'GET' });
|
|
295
|
+
console.log(JSON.stringify({ principal: me.principal, acts_for: me.acts_for ?? [] }, null, 2));
|
|
296
|
+
console.log('\nKey file: ' + keyPath + '\nServer: ' + url.origin + (serverGiven !== undefined ? ' (saved to ' + configPath() + ')' : '') + '\nTo act for someone: foundation join\nEverything else is HTTP: Authorization: Bearer $(foundation token)');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// Asking a person to make this machine their agent. One they already approved has nothing to ask.
|
|
300
|
+
if (action === 'join') {
|
|
301
|
+
const me = await send('/v1/principals/me', undefined, { method: 'GET', accept: data => data.error?.code === 'not_approved' });
|
|
302
|
+
if (me.acts_for?.length) { console.log('Already approved on ' + url.origin + '.'); return; }
|
|
303
|
+
const answer = await send('/v1/requests', { authorization_details: [{ type: 'relation', relation: 'agent' }] });
|
|
304
|
+
console.log(JSON.stringify(answer, null, 2));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Reading a secret this machine was handed an envelope for: its own, or one shown to it along a line.
|
|
308
|
+
if (action === 'read') {
|
|
309
|
+
if (!key.own) throw new Error('This machine has no key of its own here, so nothing sealed for it can be opened.');
|
|
310
|
+
const own = await send('/v1/resources?kind=secret&name=' + encodeURIComponent(call.name), undefined, { method: 'GET', accept: () => true });
|
|
311
|
+
const resource = own.resource ?? (await send('/v1/resources?shown=me', undefined, { method: 'GET' })).resources.find(item => item.kind === 'secret' && item.name === call.name);
|
|
312
|
+
if (!resource) throw new Error('No secret named ' + JSON.stringify(call.name) + ' is kept by this machine or shown to it.');
|
|
313
|
+
const kept = await send('/v1/resources/' + resource.id + '/content', undefined, { method: 'GET' });
|
|
314
|
+
if (!kept.envelope) throw new Error('No envelope was made for this machine: it may read about this secret, but was not handed its key.');
|
|
315
|
+
const contentKey = openEnvelope(Buffer.from(kept.envelope, 'base64url'), Buffer.from(key.own.private_key, 'base64url'));
|
|
316
|
+
process.stdout.write(openContent(contentKey, Buffer.from(kept.content, 'base64url')));
|
|
224
317
|
return;
|
|
225
318
|
}
|
|
226
319
|
// Nothing runs before someone has accepted this key: a key that acts for nobody reaches only its own empty resources,
|
|
@@ -232,13 +325,49 @@ async function main() {
|
|
|
232
325
|
if (!own && !current.acts_for?.length) throw new Error('Foundation request failed (401, not_approved). This key acts for nobody yet' + (current.requests?.[0] ? '; it is waiting for approval at ' + current.requests[0].verification_uri : '') + '.');
|
|
233
326
|
// Whose resources a run reaches: the one this key acts for, the one named when it acts for several, or its own.
|
|
234
327
|
const acting = current.acts_for ?? [];
|
|
235
|
-
const
|
|
236
|
-
if (!
|
|
237
|
-
const forHolder = target =>
|
|
328
|
+
const owner = process.env.FOUNDATION_AS || (acting.length === 1 ? acting[0] : null);
|
|
329
|
+
if (!owner && acting.length > 1) throw new Error('This key acts for several principals. Set FOUNDATION_AS=<principal id> to say which one this run is for.');
|
|
330
|
+
const forHolder = target => owner ? target + (target.includes('?') ? '&' : '?') + 'as=' + encodeURIComponent(owner) : target;
|
|
331
|
+
// A secret is sealed here, with a key of its own, for each of the owner's recipients: the server keeps what it
|
|
332
|
+
// cannot open. This machine is not among them; it places the bytes and does not read them back.
|
|
333
|
+
const sealedFor = async bytes => {
|
|
334
|
+
const { recipients } = await send('/v1/principals/' + encodeURIComponent(owner || 'me') + '/recipients', undefined, { method: 'GET' });
|
|
335
|
+
if (!Array.isArray(recipients) || !recipients.length) throw new Error('Nobody can open a secret kept for this owner yet: the owner needs a key, or Foundation needs to act for them.');
|
|
336
|
+
const contentKey = newContentKey();
|
|
337
|
+
return { content: sealContent(contentKey, bytes).toString('base64url'), envelopes: Object.fromEntries(recipients.map(item => [item.principal_id, seal(contentKey, Buffer.from(item.public_key, 'base64url')).toString('base64url')])) };
|
|
338
|
+
};
|
|
339
|
+
if (action === 'keep') {
|
|
340
|
+
const saved = await send(forHolder('/v1/resources?kind=secret&name=' + encodeURIComponent(call.name)), await sealedFor(call.body), { method: 'PUT' });
|
|
341
|
+
try { await send('/v1/principals/me/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
|
|
342
|
+
console.log(JSON.stringify(saved));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// A secret this machine was handed an envelope for is opened here, with its own key: the server keeps what it cannot
|
|
346
|
+
// open, and nobody else has to be able to open it for this machine to use it. Everything else - a connection, a secret
|
|
347
|
+
// not handed to this machine - is asked of the server, which hands over only what Foundation may open for the owner.
|
|
348
|
+
const handed = { environment: {}, files: [] };
|
|
349
|
+
let asked = names;
|
|
350
|
+
if (key.own && names.some(item => typeof item.name === 'string')) {
|
|
351
|
+
const shown = owner ? (await send('/v1/resources?shown=me', undefined, { method: 'GET', accept: () => true })).resources ?? [] : [];
|
|
352
|
+
asked = [];
|
|
353
|
+
for (const item of names) {
|
|
354
|
+
let resource = null;
|
|
355
|
+
if (typeof item.name === 'string') {
|
|
356
|
+
resource = owner ? shown.find(row => row.kind === 'secret' && row.name === item.name && row.owner_id === owner)
|
|
357
|
+
: (await send('/v1/resources?kind=secret&name=' + encodeURIComponent(item.name), undefined, { method: 'GET', accept: () => true })).resource;
|
|
358
|
+
}
|
|
359
|
+
const kept = resource ? await send('/v1/resources/' + resource.id + '/content', undefined, { method: 'GET', accept: () => true }) : null;
|
|
360
|
+
if (!kept?.envelope) { asked.push(item); continue; }
|
|
361
|
+
const bytes = openContent(openEnvelope(Buffer.from(kept.envelope, 'base64url'), Buffer.from(key.own.private_key, 'base64url')), Buffer.from(kept.content, 'base64url'));
|
|
362
|
+
if (item.filename !== undefined) handed.files.push({ env: item.as, filename: item.filename, content: bytes.toString('base64'), encoding: 'base64' });
|
|
363
|
+
else handed.environment[item.as] = bytes.toString('utf8');
|
|
364
|
+
}
|
|
365
|
+
}
|
|
238
366
|
let injection;
|
|
239
|
-
if (
|
|
367
|
+
if (asked.length) ({ injection } = await send(forHolder('/v1/injections'), { names: asked }));
|
|
240
368
|
else injection = { environment: {}, files: [] };
|
|
241
369
|
if (!injection || typeof injection.environment !== 'object' || !Array.isArray(injection.files)) throw new Error('Foundation returned an invalid injection.');
|
|
370
|
+
injection = { environment: { ...injection.environment, ...handed.environment }, files: [...injection.files, ...handed.files] };
|
|
242
371
|
// What each of them sets is the server's to say; this applies it and refuses anything it may not set.
|
|
243
372
|
const environment = { ...process.env };
|
|
244
373
|
delete environment.FOUNDATION_RUNTIME_KEY_FILE;
|
|
@@ -266,7 +395,7 @@ async function main() {
|
|
|
266
395
|
}
|
|
267
396
|
}
|
|
268
397
|
};
|
|
269
|
-
const recovery = () => 'Foundation could not confirm the output was saved. The private output file is retained for recovery: ' + outputPath + '\nRetry with foundation
|
|
398
|
+
const recovery = () => 'Foundation could not confirm the output was saved. The private output file is retained for recovery: ' + outputPath + '\nRetry with foundation keep <name> --from <file>, then remove that recovery file.';
|
|
270
399
|
process.once('exit', cleanup);
|
|
271
400
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, () => {
|
|
272
401
|
interrupted = true;
|
|
@@ -305,9 +434,9 @@ async function main() {
|
|
|
305
434
|
retainOutput = true;
|
|
306
435
|
// The command wrote it; the agent never saw it, and keeps it that way: the line drawn for the one who kept it is declined.
|
|
307
436
|
let saved;
|
|
308
|
-
try { saved = await send(forHolder('/v1/resources?kind=secret&name=' + encodeURIComponent(output.name)), bytes, { method: 'PUT'
|
|
437
|
+
try { saved = await send(forHolder('/v1/resources?kind=secret&name=' + encodeURIComponent(output.name)), await sealedFor(bytes), { method: 'PUT' }); }
|
|
309
438
|
catch { throw new Error(recovery()); }
|
|
310
|
-
try { await send('/v1/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
|
|
439
|
+
try { await send('/v1/principals/me/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
|
|
311
440
|
retainOutput = false;
|
|
312
441
|
console.error('Saved output as ' + JSON.stringify(output.name) + '.');
|
|
313
442
|
}
|
package/webauthn.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash, generateKeyPairSync, createPrivateKey, randomBytes, sign } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// A WebAuthn authenticator in software, for wherever there is no browser: it makes a credential and answers challenges
|
|
4
|
+
// with it, writing what a browser writes. The origin it signs is the one it is actually talking to, so a challenge
|
|
5
|
+
// relayed from elsewhere is refused there. It reports the user present - the principal running it asked - and never
|
|
6
|
+
// verified, since it checks no face, fingerprint or PIN.
|
|
7
|
+
const b64 = bytes => Buffer.from(bytes).toString('base64url');
|
|
8
|
+
const sha256 = data => createHash('sha256').update(data).digest();
|
|
9
|
+
const PRESENT = 0x01, ATTESTED = 0x40;
|
|
10
|
+
|
|
11
|
+
// Just enough CBOR (RFC 8949) for an attestation object and a COSE key: maps, byte and text strings, small integers.
|
|
12
|
+
function cbor(value) {
|
|
13
|
+
const head = (major, length) => length < 24 ? Buffer.from([major << 5 | length])
|
|
14
|
+
: length < 256 ? Buffer.from([major << 5 | 24, length]) : Buffer.from([major << 5 | 25, length >> 8, length & 255]);
|
|
15
|
+
if (Number.isInteger(value)) return value >= 0 ? head(0, value) : head(1, -1 - value);
|
|
16
|
+
if (Buffer.isBuffer(value)) return Buffer.concat([head(2, value.length), value]);
|
|
17
|
+
if (typeof value === 'string') { const text = Buffer.from(value); return Buffer.concat([head(3, text.length), text]); }
|
|
18
|
+
const entries = value instanceof Map ? [...value] : Object.entries(value);
|
|
19
|
+
return Buffer.concat([head(5, entries.length), ...entries.flatMap(([key, item]) => [cbor(key), cbor(item)])]);
|
|
20
|
+
}
|
|
21
|
+
const counter = count => { const bytes = Buffer.alloc(4); bytes.writeUInt32BE(count); return bytes; };
|
|
22
|
+
const clientData = (type, challenge, origin) => Buffer.from(JSON.stringify({ type, challenge, origin, crossOrigin: false }));
|
|
23
|
+
|
|
24
|
+
// Makes a credential for the options a server gave. Returns what to send back, and what to keep: the credential's id, its
|
|
25
|
+
// private key, and whose it is.
|
|
26
|
+
export function createCredential(options, origin) {
|
|
27
|
+
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
28
|
+
const point = publicKey.export({ format: 'jwk' }), id = randomBytes(32);
|
|
29
|
+
const cose = cbor(new Map([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(point.x, 'base64url')], [-3, Buffer.from(point.y, 'base64url')]]));
|
|
30
|
+
const authData = Buffer.concat([sha256(options.rp.id), Buffer.from([PRESENT | ATTESTED]), counter(0), Buffer.alloc(16),
|
|
31
|
+
Buffer.from([id.length >> 8, id.length & 255]), id, cose]);
|
|
32
|
+
const response = { id: b64(id), rawId: b64(id), type: 'public-key', clientExtensionResults: {},
|
|
33
|
+
response: { clientDataJSON: b64(clientData('webauthn.create', options.challenge, origin)), attestationObject: b64(cbor({ fmt: 'none', attStmt: {}, authData })), transports: [] } };
|
|
34
|
+
return { response, credential: { id: b64(id), user: options.user.id, private_key: privateKey.export({ format: 'jwk' }) } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Answers a sign-in challenge with a kept credential.
|
|
38
|
+
export function answer(options, credential, origin) {
|
|
39
|
+
const data = clientData('webauthn.get', options.challenge, origin);
|
|
40
|
+
const authData = Buffer.concat([sha256(options.rpId), Buffer.from([PRESENT]), counter(0)]);
|
|
41
|
+
const signature = sign('sha256', Buffer.concat([authData, sha256(data)]), createPrivateKey({ key: credential.private_key, format: 'jwk' }));
|
|
42
|
+
return { id: credential.id, rawId: credential.id, type: 'public-key', clientExtensionResults: {},
|
|
43
|
+
response: { clientDataJSON: b64(data), authenticatorData: b64(authData), signature: b64(signature), userHandle: credential.user } };
|
|
44
|
+
}
|