@ai-ecoverse/slicc-vault 1.8.4 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ The build (`npm run build`) writes a flat static tree to `dist/site/`:
|
|
|
17
17
|
| `vault.css`, `fonts/` | Spectrum 2 from the CSS-only `@spectrum-css` packages, and Adobe Clean |
|
|
18
18
|
| `config.json` | `{ version, embedders }`. `embedders` is `vault.sliccy.ai`'s fallback list, `["https://seven.sliccy.ai"]` |
|
|
19
19
|
|
|
20
|
-
The bundles hold this repo's code
|
|
20
|
+
The bundles hold this repo's code, `@ai-ecoverse/slicc-scrub`, and `@ai-ecoverse/jerk-oauth` (certified 0.15.1, exact-pinned, for token refresh): its wasm is inlined into a separate chunk under `chunks/` (about 970 KB) that loads only on the first refresh or MCP `grant`, never fetched. The only third-party code is for KeePass imports: `kdbxweb` (with `@xmldom/xmldom` and `fflate`) and `hash-wasm`'s argon2, exact-pinned and bundled into a separate chunk under `chunks/` that the core frame loads only when a KeePass import starts; its wasm is inlined, never fetched. The sliccy-ai Worker serves the tree from the network on every load, never from OPFS, with:
|
|
21
21
|
- `Content-Security-Policy` with `script-src 'self'`, no inline scripts or styles, and `frame-ancestors` listing the seven origins;
|
|
22
22
|
- `Cross-Origin-Embedder-Policy: require-corp`, `Cross-Origin-Resource-Policy: same-site`, `Cross-Origin-Opener-Policy: same-origin`;
|
|
23
23
|
- `Origin-Agent-Cluster: ?1`, `nosniff`, `Referrer-Policy: no-referrer`, `no-cache` with an ETag.
|
|
@@ -164,7 +164,7 @@ iframe.contentWindow.postMessage({ type: 'slicc-vault', v: 1, op: 'hello', role:
|
|
|
164
164
|
|
|
165
165
|
| op (page role) | fields | reply |
|
|
166
166
|
|---|---|---|
|
|
167
|
-
| `sealed` | `req` (the `/dev/sealed` request object, or its JSON text), `stamp` (`{ pid, ppid, uid, gid, tag
|
|
167
|
+
| `sealed` | `req` (the `/dev/sealed` request object, or its JSON text), `stamp` (the kernel's `{ pid, ppid, uid, gid, tag, users, groups?, user? }`) | `{ reply }`, the §3.7 reply, below |
|
|
168
168
|
| `status` | — | `{ status: { version, protocol, origin, locked, lock, scrub, waiting, exportedAt, backupReminder, policy } }` |
|
|
169
169
|
| `placeholders` | — | `{ placeholders, scrub }`: every placeholder of a record that isn't removed, active and inert, sorted |
|
|
170
170
|
| `list` | — | `{ list: { secrets, refused, audit, auditMore, exits } }` for Settings › Secrets |
|
|
@@ -222,13 +222,13 @@ bios relays each kernel request unchanged as the `sealed` op, with the kernel's
|
|
|
222
222
|
|
|
223
223
|
The vault has no network stack, so sign-ins run in seven's page (route (b), §3.11) and the vault keeps only the results.
|
|
224
224
|
- **A sign-in's tokens:** bios runs the sign-in, does the code exchange, and hands the results to `seal`, keeping no copy:
|
|
225
|
-
- the refresh token as `{ kind: 'grant', name, value, source: 'oauth:<provider>', oauth: { tokenUrl, clientId, scope?, resource? } }`. A grant is never injected and never exported; `tokenUrl` must be `https:`, without userinfo (`user:pw@`, `user@`, even an empty `:@`, also when hidden by whitespace or backslashes the URL parser normalises: any `@` in the authority refuses);
|
|
225
|
+
- the refresh token as `{ kind: 'grant', name, value, source: 'oauth:<provider>', oauth: { tokenUrl, clientId, scope?, resource? } }`. A grant is never injected and never exported; `tokenUrl` must be `https:`, without userinfo (`user:pw@`, `user@`, even an empty `:@`, also when hidden by whitespace or backslashes the URL parser normalises: any `@` in the authority refuses), and a `resource` must be an `https:` URL (or `http:` on 127.0.0.1, localhost or [::1]) without userinfo or fragment;
|
|
226
226
|
- the access token as `{ name, value, source: 'oauth:<provider>', expiresAt, refresh: '<grant name>' }`. A grant name needs an `oauth:` source and `jerk-seal` a `jerk:` one (`EINVAL`), and a refresh uses the grant only when its source is the token's own (else the grant is missing).
|
|
227
227
|
- With `request` (only for a `secret`; the id of a pending `oauth` request for the same provider as `oauth:<provider>`, or a `jerk-seal` request for the provider of `jerk:<provider>@…`) and `jerk` (`{ exit, user?, scopes?, expires_at? }`), `seal` also answers that request, in the same transaction that stores the record (a request that ended meanwhile refuses with `EINVAL`, and nothing is stored): its `/dev/sealed` `status` becomes `done`, with `jerk`, the record's `placeholder` and `record` state.
|
|
228
228
|
- **A J1 lease:** `{ name, value, source: 'jerk:<provider>@<device>', expiresAt, refresh: 'jerk-seal' }`.
|
|
229
229
|
- **When a token is due** (5 minutes before `expiresAt`): the core frame checks once a minute, and right after a page says hello, for every connected namespace and for `secret`, `model` and `mcp` records alike, and `refresh { name }` asks for it at once. Vault frames of the same storage (several tabs) take a Web Lock per refresh source, so one grant is sent to its IdP by one frame at a time; a frame that finds the token changed while it waited (refreshed or sealed again) sends nothing and answers with the stored token's `{ state: 'refreshed', expiresAt? }`.
|
|
230
230
|
- A `jerk-seal` token is announced once per `expiresAt` (kept in IndexedDB, so across frames), and again every 10 minutes while it stays unsealed, in case no live page heard it, with the `refresh` event, and bios runs J1 `seal` again (`{ state: 'requested' }`).
|
|
231
|
-
- An OAuth token is refreshed in the vault:
|
|
231
|
+
- An OAuth token is refreshed in the vault with jerk-oauth: `refreshTokenRequestBytes` builds `POST <tokenUrl, serialized>` with `grant_type=refresh_token`, the refresh token, `client_id` and any `scope` and `resource` (form-encoded), from a byte buffer it zeroes, and the vault sends it through bios with the `egress` event. bios sends the request through its exits and posts `{ status, headers, body }` (or `{ error }`) on the event's port within 30 s. The event goes to the page port of the namespace that connected last. A port that doesn't answer in time (`ETIMEDOUT`), closed or not, keeps its ops and events but gets no more `egress`, and the next refresh goes to the page port before it. The vault checks the response with its own rules below and then with jerk-oauth's `parseRefreshResponse` (with the clock as whole seconds; a clock that isn't a safe integer is `EACCES: refresh failed: the vault's clock is invalid`), reads `access_token`, `expires_in` and any new `refresh_token`, checks them as values, and rotates the records in one transaction with the `refresh` audit entry (their placeholders stay; if the token or its grant was changed, removed or restored during the round trip, nothing is written and it answers `EAGAIN`). An `expires_in` of 0 makes the token due at once, and one that isn't a whole number from 0 to ten years is `EACCES: refresh failed: the IdP returned an invalid expires_in` (likewise a `refresh_token` that is present but not a string, a `token_type` that isn't `Bearer` in any case or is missing, and `access_token`, `refresh_token`, `token_type` or `expires_in` given twice: `EACCES: refresh failed: the IdP returned NAME twice`); a response carrying `error` fails even with a token; a response without `expires_in` leaves the new token without an expiry, so it isn't refreshed again until a new one is sealed. It answers `{ state: 'refreshed', expiresAt? }`, without `expiresAt` when the token has none. A failed refresh is `EACCES: refresh failed: <the IdP's error code>`, scrubbed; a code that isn't 1–128 characters of RFC 6749's error charset reads "the IdP sent an unreadable error", and `error_description` is never shown. Any other response jerk-oauth refuses is `EACCES: refresh failed: the IdP response is invalid`. MCP `grant` responses are parsed the same way. Without a connected page it's `ENOTCONN`.
|
|
232
232
|
- `egress` carries the refresh token to the page, in transit only; the response goes back to the vault, never to the kernel.
|
|
233
233
|
|
|
234
234
|
## Import and export
|
|
@@ -262,7 +262,7 @@ Values are checked as at `put` (at least 8 bytes, no CR, LF or NUL, no placehold
|
|
|
262
262
|
|
|
263
263
|
`fnox exec` (§3.5) asks for a one-shot ticket, and the kernel redeems it when it execs the child.
|
|
264
264
|
|
|
265
|
-
**`materialize`** (a `/dev/sealed` op) takes `names`, `path` (the absolute program fnox will exec), `argv` (exact, `argv[0]` included), `cwd`, and optionally `interpreter`: fnox's claim of the absolute shebang or binfmt interpreter of `path`, omitted for a native program. It's only a claim; the kernel's chain at consume decides. `shellGrants: true` (`fnox exec --shell-grants`) adds every `shell = true` secret to `names`, which may then be omitted.
|
|
265
|
+
**`materialize`** (a `/dev/sealed` op) takes `names`, `path` (the absolute program fnox will exec), `argv` (exact, `argv[0]` included), `cwd`, and optionally `interpreter`: fnox's claim of the absolute shebang or binfmt interpreter of `path`, omitted for a native program. It's only a claim; the kernel's chain at consume decides. `shellGrants: true` (`fnox exec --shell-grants`) adds every `shell = true` secret to `names`, which may then be omitted. While the caller's stamp doesn't say `users: 'enforce'`, it's `EACCES` "kernel users must be enforce" in every `[exec]` mode, `shell` included (§3.16.2): with users off, every process is effectively root and the stamp proves nothing.
|
|
266
266
|
- Each name must be a `secret` record (`ENOENT` otherwise) that hasn't expired (`EACCES: NAME expired; refreshing`). `real` says which will get their value: removed records and secrets without an `[exec]` grant stay `false` and keep their placeholder.
|
|
267
267
|
- **`true`** (exec `any`): refused with `EACCES` when `path`, or `interpreter`, is a shell (`sh`, `bash`, `dash`, `zsh`, `fish`, `busybox`, `jsh`), an interpreter (`python*`, `node`, `deno`, `bun`, `perl`, `ruby`, `php`, `lua`, `wasmer`, `awk` and `gawk`/`mawk`/`nawk`, `sed`, `tclsh`, `expect`, `R`, `Rscript`, `irb`) or an env dumper or wrapper (`env`, `printenv`, `xargs`, `sudo`, `su`, `nohup`, `timeout`, `time`, `nice`, `strace`), by basename.
|
|
268
268
|
- **`{ argv = [[…]] }`**: `real` only when `[path, ...argv[1..]]` starts with one of the listed prefixes. A listed shell or interpreter is allowed.
|
|
@@ -276,10 +276,10 @@ Values are checked as at `put` (at least 8 bytes, no CR, LF or NUL, no placehold
|
|
|
276
276
|
- `stamp` is the exec'ing process.
|
|
277
277
|
- `resolved` is the kernel-normalised file that actually runs: `path` with every symlink resolved.
|
|
278
278
|
- `interpreters` is the absolute, symlink-resolved shebang/binfmt chain the kernel resolved for `path`, empty for a native binary. A plain `#!/usr/bin/env X` (or `env -S`) is reported as the resolved `X`; `env` with any other option or a `NAME=value` assignment is reported as `env` itself.
|
|
279
|
-
- `trusted` is true when the program and every chain entry are root-owned regular files, not group- or other-writable, under root-owned, non-writable parents (the attestation rule). Under users `off` it's always false.
|
|
279
|
+
- `trusted` is true when the program and every chain entry are root-owned regular files, not group- or other-writable, under root-owned, non-writable parents (the attestation rule). Under users `off` it's always false, and the consume is refused anyway.
|
|
280
280
|
|
|
281
281
|
The vault burns the ticket, then requires that:
|
|
282
|
-
- it's unexpired and from this namespace, `stamp.ppid` is the pid that asked, and `path`, `argv` (element by element) and `cwd` are exactly the ones it was issued for;
|
|
282
|
+
- `stamp.users` is `enforce` (else "kernel users must be enforce"), it's unexpired and from this namespace, `stamp.ppid` is the pid that asked, and `path`, `argv` (element by element) and `cwd` are exactly the ones it was issued for;
|
|
283
283
|
- `resolved` is an absolute path (missing or malformed refuses), `interpreters` a list of absolute paths and `trusted` a boolean; a claimed `interpreter` from fnox equals `interpreters[0]` (without a claim, the kernel's chain is taken as it is);
|
|
284
284
|
- each real name is still granted by the policy as stored now (reloaded at materialize and at consume): a removed grant, or an `{ argv }` grant that no longer lists the command, refuses;
|
|
285
285
|
- under that name's mode, the chain passes:
|
|
@@ -299,13 +299,13 @@ const channel = new MessageChannel();
|
|
|
299
299
|
port.postMessage({ v: 1, id, op: 'forward', head, body, owner, exit, rawTraits }, [channel.port2]);
|
|
300
300
|
```
|
|
301
301
|
|
|
302
|
-
- `head` is `{ method, url, headers: [name, value][] }`; `owner` the kernel's `{ pid, ppid, uid, gid, tag }
|
|
302
|
+
- `head` is `{ method, url, headers: [name, value][] }`; `owner` the kernel's `{ pid, ppid, uid, gid, tag, users, groups?, user? }` (`users` must be `enforce`, else nothing is injected); `exit` the exit bios chose (`page`, `proxy`, `tailscale` or `device:<name>`); `rawTraits` that exit's `{ manualRedirects?, encodedBodies? }`. `body` (bytes or a string) is needed only for MCP requests (below); for every other request it stays in bios, because bodies are never injected.
|
|
303
303
|
- **The reply** is `{ head }`, the same head with the values filled in, or `{ refused: { status, reason } }`: `403` with a one-line reason, or `502` for an exit with encoded bodies. A refused request must not be sent anywhere. Malformed fields are `EINVAL`.
|
|
304
304
|
- **The response** goes back over `channel.port1`, scrubbed: bios posts `{ op: 'response', status, statusText, url, redirected?, headers }`, then any number of `{ op: 'body', chunk: Uint8Array }`, then `{ op: 'end' }` or `{ op: 'error', message }`. The vault answers with the same messages, with `statusText`, `url`, every header name and value, the body and the error scrubbed over every value of the namespace. A value in its raw form comes back as its placeholder, anything else as `[redacted:NAME]`. The body is scrubbed as a stream; after 250 ms without input, a held tail shorter than 16 bytes is let through.
|
|
305
305
|
|
|
306
306
|
**The injector**, fnox's proxy semantics:
|
|
307
307
|
- **Hosts** are compared lower-cased, without a trailing dot, in rules, the refusal list and MCP servers alike.
|
|
308
|
-
- **Refused before anything is sent:** a placeholder in the URL or in a header name; credentials in the URL; a scheme other than `https:` or a port other than 443 (except §3.16.3's kernel servers, below); an ambiguous path (`.`/`..` segments, `\`, `%2e`, `%2f`, `%5c`, `%25`, checked on the raw URL); an exit with encoded bodies; no placeholder of this vault at all; a record that is removed, expired ("NAME expired; refreshing") or not a `secret`; a locked vault; no rule (in `/etc/fnox/config.toml`) whose exact domain, header, method and path allow it; an exit the secret's `[exits]` line doesn't list; the same placeholder twice in one header value; a header other than `authorization` on an exit that follows redirects (`manualRedirects` false); CR, LF or NUL after substitution.
|
|
308
|
+
- **Refused before anything is sent:** an `owner` whose `users` isn't `enforce` ("kernel users must be enforce", §3.16.2; this covers kernel programs calling a registered MCP server, and the `/dev/sealed` `test` verdict, too); a placeholder in the URL or in a header name; credentials in the URL; a scheme other than `https:` or a port other than 443 (except §3.16.3's kernel servers, below); an ambiguous path (`.`/`..` segments, `\`, `%2e`, `%2f`, `%5c`, `%25`, checked on the raw URL); an exit with encoded bodies; no placeholder of this vault at all; a record that is removed, expired ("NAME expired; refreshing") or not a `secret`; a locked vault; no rule (in `/etc/fnox/config.toml`) whose exact domain, header, method and path allow it; an exit the secret's `[exits]` line doesn't list; the same placeholder twice in one header value; a header other than `authorization` on an exit that follows redirects (`manualRedirects` false); CR, LF or NUL after substitution.
|
|
309
309
|
- **Paths** in rules are globs: `*` stays within a segment, `**` crosses segments, `?` is one character.
|
|
310
310
|
- **Injected:** each placeholder is replaced inside the rule's header value, so `Bearer …` and `token …` templates work. In `Authorization: Basic`, the credential is decoded, the user and password parts substituted (a substituted user may not contain `:`), and re-encoded. A Basic value that holds a placeholder but isn't valid base64 of UTF-8 is refused.
|
|
311
311
|
- **Audited:** each injection adds a `forward` audit entry `{ name, domain, method, path, rule, exit, uid, tag }` and sets the record's `usedAt`. Never headers, bodies or values.
|
|
@@ -430,7 +430,7 @@ npm test # Chromium integration tests
|
|
|
430
430
|
npm start # vault on http://vault.sliccy.localhost:8790, a test embedder on seven.sliccy.localhost
|
|
431
431
|
```
|
|
432
432
|
|
|
433
|
-
- **Integration tests** (`test/integration/`) run the built vault on a local server that sends the production headers, at `http://vault.sliccy.localhost:<port>`, with test embedders on `seven.`, `preview.` and `evil.sliccy.localhost`. Chromium treats `*.sliccy.localhost` as one site and as a secure context, so the embedded frame gets the same first-party storage it gets under `sliccy.ai`. They use slicc-shared-web's CDP harness, plus a second CDP session for popups and the virtual WebAuthn authenticator.
|
|
433
|
+
- **Integration tests** (`test/integration/`) run the built vault on a local server that sends the production headers, at `http://vault.sliccy.localhost:<port>`, with test embedders on `seven.`, `preview.` and `evil.sliccy.localhost`. Chromium treats `*.sliccy.localhost` as one site and as a secure context, so the embedded frame gets the same first-party storage it gets under `sliccy.ai`. They use slicc-shared-web's CDP harness, plus a second CDP session for popups and the virtual WebAuthn authenticator. `kernel.test.mjs` runs exec tickets end to end against the real kernel (`@ai-ecoverse/slicc-kernel/node` with wasm-bash and wasm-coreutils, test dependencies only): its `/dev/sealed` hook relays to the built vault, so a `materialize`, the spawn with `SLICC_SEALED_TICKET`, the kernel's `materializeConsume`, the value swap and S2's scrubbing of the child's output all run as in seven, and with users `off` nothing is revealed.
|
|
434
434
|
- **Unit tests** stay in the gitignored `test/unit/` (`npm run test:unit`), and the pre-commit hook requires them to cover every changed line of `src/`.
|
|
435
435
|
- The Biome, TypeScript, lefthook and Renovate configuration comes from [slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web). There are no comments in any file.
|
|
436
436
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-vault",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "The separate-origin vault at vault.sliccy.ai: sealed secrets, OAuth tokens, and model and MCP credentials for SLICC seven, used only by its injector",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
"dist/types"
|
|
32
32
|
],
|
|
33
33
|
"scripts": {
|
|
34
|
-
"prepare": "node scripts/endpoints.mjs && { [ ! -e .git ] || lefthook install; }",
|
|
35
|
-
"build": "node scripts/endpoints.mjs && node build.mjs && tsc -p tsconfig.build.json",
|
|
34
|
+
"prepare": "node scripts/endpoints.mjs && node scripts/jerk-wasm.mjs && { [ ! -e .git ] || lefthook install; }",
|
|
35
|
+
"build": "node scripts/endpoints.mjs && node scripts/jerk-wasm.mjs && node build.mjs && tsc -p tsconfig.build.json",
|
|
36
36
|
"prepack": "npm run build",
|
|
37
37
|
"start": "npm run build && node test/integration/serve.mjs",
|
|
38
38
|
"pretest": "npm run build && playwright-core install --no-shell chromium",
|
|
39
39
|
"test": "node --test --test-timeout=300000 --test-global-setup=node_modules/@ai-ecoverse/slicc-shared-web/harness/global.mjs 'test/integration/*.test.mjs'",
|
|
40
|
-
"test:unit": "node scripts/endpoints.mjs && NODE_NO_WARNINGS=1 mcr -c node_modules/@ai-ecoverse/slicc-shared-web/mcr.config.mjs node --import fake-indexeddb/auto --test --test-force-exit 'test/unit/**/*.test.mjs'",
|
|
40
|
+
"test:unit": "node scripts/endpoints.mjs && node scripts/jerk-wasm.mjs && NODE_NO_WARNINGS=1 mcr -c node_modules/@ai-ecoverse/slicc-shared-web/mcr.config.mjs node --import fake-indexeddb/auto --test --test-force-exit 'test/unit/**/*.test.mjs'",
|
|
41
41
|
"typecheck": "tsc",
|
|
42
42
|
"lint": "slicc-lint",
|
|
43
43
|
"format": "biome check --write .",
|
|
@@ -46,8 +46,12 @@
|
|
|
46
46
|
"release": "semantic-release"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
+
"@ai-ecoverse/jerk-oauth": "0.15.2",
|
|
50
|
+
"@ai-ecoverse/slicc-kernel": "1.83.0",
|
|
49
51
|
"@ai-ecoverse/slicc-scrub": "1.0.0",
|
|
50
52
|
"@ai-ecoverse/slicc-shared-web": "1.13.1",
|
|
53
|
+
"@ai-ecoverse/wasm-bash": "5.3.0-18",
|
|
54
|
+
"@ai-ecoverse/wasm-coreutils": "9.12.0-5",
|
|
51
55
|
"@biomejs/biome": "2.5.15",
|
|
52
56
|
"@earendil-works/pi-ai": "1.0.4",
|
|
53
57
|
"@happy-dom/global-registrator": "20.14.5",
|
|
@@ -60,7 +64,7 @@
|
|
|
60
64
|
"hash-wasm": "4.12.0",
|
|
61
65
|
"kdbxweb": "2.1.1",
|
|
62
66
|
"lefthook": "2.1.17",
|
|
63
|
-
"monocart-coverage-reports": "2.13.
|
|
67
|
+
"monocart-coverage-reports": "2.13.2",
|
|
64
68
|
"playwright-core": "1.63.0",
|
|
65
69
|
"semantic-release": "25.0.9",
|
|
66
70
|
"typescript": "7.0.2"
|