@tomato414941/foundation 0.18.0 → 0.23.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/package.json +1 -1
- package/runtime.mjs +35 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomato414941/foundation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
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": {
|
package/runtime.mjs
CHANGED
|
@@ -231,9 +231,9 @@ async function main() {
|
|
|
231
231
|
async function upgrade(label) {
|
|
232
232
|
const me = await send('/v1/principals/me', undefined, { method: 'GET' });
|
|
233
233
|
if (me.key?.environment) return;
|
|
234
|
-
const { options } = await send('/v1/
|
|
234
|
+
const { options } = await send('/v1/principals/' + encodeURIComponent(me.principal.id) + '/credentials', { kind: 'webauthn' });
|
|
235
235
|
const made = createCredential(options, url.origin);
|
|
236
|
-
await send('/v1/
|
|
236
|
+
await send('/v1/principals/' + encodeURIComponent(me.principal.id) + '/credentials', { kind: 'webauthn', name: label, credential: made.response }, { method: 'PUT' });
|
|
237
237
|
await writeKey(keyPath, JSON.stringify({ webauthn_credential: made.credential }), !process.env.FOUNDATION_RUNTIME_KEY_FILE);
|
|
238
238
|
key = { credential: made.credential };
|
|
239
239
|
}
|
|
@@ -242,7 +242,7 @@ async function main() {
|
|
|
242
242
|
async function publishKey() {
|
|
243
243
|
if (key.own) return;
|
|
244
244
|
const made = generateKey();
|
|
245
|
-
const published = await send('/v1/key', { public_key: made.publicKey.toString('base64url') }, { method: 'PUT', accept: data => data.error?.code === 'key_exists' });
|
|
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
246
|
if (published.error) return;
|
|
247
247
|
key.own = { private_key: made.privateKey.toString('base64url') };
|
|
248
248
|
await writeKey(keyPath, JSON.stringify({ webauthn_credential: key.credential, key: key.own }), !process.env.FOUNDATION_RUNTIME_KEY_FILE);
|
|
@@ -256,10 +256,6 @@ async function main() {
|
|
|
256
256
|
if (action === 'token') { console.log(token); return; }
|
|
257
257
|
// One request, as this machine, and the answer printed as it came. Nothing here knows the endpoints.
|
|
258
258
|
if (action === 'api') {
|
|
259
|
-
if (!publicSpec && !/[?&]as=/.test(call.target)) {
|
|
260
|
-
const me = await send('/v1/principals/me', undefined, { method: 'GET', accept: () => true });
|
|
261
|
-
if (me.acts_for?.length === 1) call.target += (call.target.includes('?') ? '&' : '?') + 'as=' + encodeURIComponent(me.acts_for[0]);
|
|
262
|
-
}
|
|
263
259
|
const response = await fetch(url.origin + call.target, { method: call.method, headers: { ...(token ? { authorization: 'Bearer ' + token } : {}), ...(call.body === undefined ? {} : { 'content-type': call.type }) },
|
|
264
260
|
...(call.body === undefined ? {} : { body: call.body }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
265
261
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
@@ -278,7 +274,7 @@ async function main() {
|
|
|
278
274
|
// No key, or one this server does not know: a WebAuthn credential made here, proven by nobody, makes this machine a
|
|
279
275
|
// principal there, of nobody's - the same call a browser's passkey makes.
|
|
280
276
|
key = null; me = null;
|
|
281
|
-
const post = (method, payload) => fetch(url.origin + '/v1/
|
|
277
|
+
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
278
|
const begun = await post('POST', { kind: 'webauthn', name: wanted });
|
|
283
279
|
const { options, error } = await begun.json();
|
|
284
280
|
if (!begun.ok) throw new Error('Foundation did not begin a credential (' + begun.status + ', ' + (error?.code || 'unknown') + ').');
|
|
@@ -307,8 +303,8 @@ async function main() {
|
|
|
307
303
|
// Reading a secret this machine was handed an envelope for: its own, or one shown to it along a line.
|
|
308
304
|
if (action === 'read') {
|
|
309
305
|
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);
|
|
306
|
+
const own = await send('/v1/principals/me/resources?kind=secret&name=' + encodeURIComponent(call.name), undefined, { method: 'GET', accept: () => true });
|
|
307
|
+
const resource = own.resource ?? (await send('/v1/principals/me/resources?shown=me', undefined, { method: 'GET' })).resources.find(item => item.kind === 'secret' && item.name === call.name);
|
|
312
308
|
if (!resource) throw new Error('No secret named ' + JSON.stringify(call.name) + ' is kept by this machine or shown to it.');
|
|
313
309
|
const kept = await send('/v1/resources/' + resource.id + '/content', undefined, { method: 'GET' });
|
|
314
310
|
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.');
|
|
@@ -327,25 +323,47 @@ async function main() {
|
|
|
327
323
|
const acting = current.acts_for ?? [];
|
|
328
324
|
const owner = process.env.FOUNDATION_AS || (acting.length === 1 ? acting[0] : null);
|
|
329
325
|
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
|
|
326
|
+
const holder = '/v1/principals/' + encodeURIComponent(owner || 'me');
|
|
331
327
|
// A secret is sealed here, with a key of its own, for each of the owner's recipients: the server keeps what it
|
|
332
328
|
// cannot open. This machine is not among them; it places the bytes and does not read them back.
|
|
333
329
|
const sealedFor = async bytes => {
|
|
334
|
-
const { recipients } = await send(
|
|
330
|
+
const { recipients } = await send(holder + '/recipients', undefined, { method: 'GET' });
|
|
335
331
|
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
332
|
const contentKey = newContentKey();
|
|
337
333
|
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
334
|
};
|
|
339
335
|
if (action === 'keep') {
|
|
340
|
-
const saved = await send(
|
|
341
|
-
try { await send('/v1/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
|
|
336
|
+
const saved = await send(holder + '/resources?kind=secret&name=' + encodeURIComponent(call.name), await sealedFor(call.body), { method: 'PUT' });
|
|
337
|
+
try { await send('/v1/principals/me/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
|
|
342
338
|
console.log(JSON.stringify(saved));
|
|
343
339
|
return;
|
|
344
340
|
}
|
|
341
|
+
// A secret this machine was handed an envelope for is opened here, with its own key: the server keeps what it cannot
|
|
342
|
+
// open, and nobody else has to be able to open it for this machine to use it. Everything else - a connection, a secret
|
|
343
|
+
// not handed to this machine - is asked of the server, which hands over only what Foundation may open for the owner.
|
|
344
|
+
const handed = { environment: {}, files: [] };
|
|
345
|
+
let asked = names;
|
|
346
|
+
if (key.own && names.some(item => typeof item.name === 'string')) {
|
|
347
|
+
const shown = owner ? (await send('/v1/principals/me/resources?shown=me', undefined, { method: 'GET', accept: () => true })).resources ?? [] : [];
|
|
348
|
+
asked = [];
|
|
349
|
+
for (const item of names) {
|
|
350
|
+
let resource = null;
|
|
351
|
+
if (typeof item.name === 'string') {
|
|
352
|
+
resource = owner ? shown.find(row => row.kind === 'secret' && row.name === item.name && row.owner_id === owner)
|
|
353
|
+
: (await send('/v1/principals/me/resources?kind=secret&name=' + encodeURIComponent(item.name), undefined, { method: 'GET', accept: () => true })).resource;
|
|
354
|
+
}
|
|
355
|
+
const kept = resource ? await send('/v1/resources/' + resource.id + '/content', undefined, { method: 'GET', accept: () => true }) : null;
|
|
356
|
+
if (!kept?.envelope) { asked.push(item); continue; }
|
|
357
|
+
const bytes = openContent(openEnvelope(Buffer.from(kept.envelope, 'base64url'), Buffer.from(key.own.private_key, 'base64url')), Buffer.from(kept.content, 'base64url'));
|
|
358
|
+
if (item.filename !== undefined) handed.files.push({ env: item.as, filename: item.filename, content: bytes.toString('base64'), encoding: 'base64' });
|
|
359
|
+
else handed.environment[item.as] = bytes.toString('utf8');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
345
362
|
let injection;
|
|
346
|
-
if (
|
|
363
|
+
if (asked.length) ({ injection } = await send(holder + '/injections', { names: asked }));
|
|
347
364
|
else injection = { environment: {}, files: [] };
|
|
348
365
|
if (!injection || typeof injection.environment !== 'object' || !Array.isArray(injection.files)) throw new Error('Foundation returned an invalid injection.');
|
|
366
|
+
injection = { environment: { ...injection.environment, ...handed.environment }, files: [...injection.files, ...handed.files] };
|
|
349
367
|
// What each of them sets is the server's to say; this applies it and refuses anything it may not set.
|
|
350
368
|
const environment = { ...process.env };
|
|
351
369
|
delete environment.FOUNDATION_RUNTIME_KEY_FILE;
|
|
@@ -412,9 +430,9 @@ async function main() {
|
|
|
412
430
|
retainOutput = true;
|
|
413
431
|
// 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.
|
|
414
432
|
let saved;
|
|
415
|
-
try { saved = await send(
|
|
433
|
+
try { saved = await send(holder + '/resources?kind=secret&name=' + encodeURIComponent(output.name), await sealedFor(bytes), { method: 'PUT' }); }
|
|
416
434
|
catch { throw new Error(recovery()); }
|
|
417
|
-
try { await send('/v1/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
|
|
435
|
+
try { await send('/v1/principals/me/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
|
|
418
436
|
retainOutput = false;
|
|
419
437
|
console.error('Saved output as ' + JSON.stringify(output.name) + '.');
|
|
420
438
|
}
|