@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 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.15.0",
4
- "description": "Foundation CLI: make a key, and hand what is kept to a command without it passing through the agent.",
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
- // connect say which server, and make the key. It has to exist as a private file before anything can be asked, and whoever
17
- // makes it must not print it.
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: what Foundation issued, kept private. Nothing here makes a key; Foundation does, once, when this
24
- // machine becomes a principal, and the file is the only place it lives afterwards.
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 > 512 || (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).');
31
- const token = (await handle.readFile('utf8')).trim();
32
- if (!/^fdn_[A-Za-z0-9_-]{43}$/.test(token)) throw new Error('Invalid runtime key file.');
33
- return token;
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 connect'); }
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, token, privateDirectory) {
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(token + '\n'); await created.sync(); } finally { await created.close(); }
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: `connect <url>` writes it here,
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 connect <url>'); }
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
- connect [<url>] [--name <name>] Make this machine's key and ask the owner to approve it.
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 with the key attached.
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 connect).
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, connectTo, name;
161
- if (action === 'connect') {
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: connect [<url>] [--name <name>]');
164
- connectTo = parsed.positionals[0];
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: connect [<url>] [--name <name>] | exec [<ENV>=<name> ... | --inputs <json>] [--output <json>] -- <command> [args...] | api <method> </path> [--json <body>] [--from <file>]');
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(connectTo ?? configured);
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 token = publicSpec ? null : await readKey(keyPath, { missingOk: action === 'connect' });
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
- // One request, with the key attached and the answer printed as it came. Nothing here knows the endpoints.
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
- // Asking the owner to approve this key. The key itself is never printed: it stays in the file.
206
- // A key the owner already approved has nothing to ask; connecting again only changes which server is remembered.
207
- if (action === 'connect') {
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
- const response = await fetch(url.origin + '/v1/principals', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: wanted }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
215
- const made = await response.json();
216
- if (!response.ok || !/^fdn_[A-Za-z0-9_-]{43}$/.test(made.token ?? '')) throw new Error('Foundation did not issue a key (' + response.status + ', ' + (made.error?.code || 'unknown') + ').');
217
- await writeKey(keyPath, made.token, !process.env.FOUNDATION_RUNTIME_KEY_FILE);
218
- token = made.token; me = null;
219
- }
220
- const answer = me?.acts_for?.length ? null : await send('/v1/requests', { authorization_details: [{ type: 'relation', relation: 'actor' }] });
221
- if (connectTo !== undefined) await saveUrl(url.origin);
222
- console.log(answer === null ? 'Already approved on ' + url.origin + '.' : JSON.stringify(answer, null, 2));
223
- console.log('\nKey file: ' + keyPath + '\nServer: ' + url.origin + (connectTo !== undefined ? ' (saved to ' + configPath() + ')' : '') + '\nEverything else is HTTP: Authorization: Bearer <the contents of that file>');
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 holder = process.env.FOUNDATION_AS || (acting.length === 1 ? acting[0] : null);
236
- if (!holder && 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.');
237
- const forHolder = target => holder ? target + (target.includes('?') ? '&' : '?') + 'as=' + encodeURIComponent(holder) : 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 (names.length) ({ injection } = await send(forHolder('/v1/injections'), { names }));
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 api PUT "/v1/resources?kind=secret&name=<URL-encoded-name>" --from <file>, then remove that recovery file.';
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', type: 'application/octet-stream' }); }
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
+ }