@ai-ecoverse/slicc-kernel 1.150.0 → 1.151.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/README.md CHANGED
@@ -508,8 +508,8 @@ The module's default export (or its `main` export) is called with a context and
508
508
  - **A TLS server** ([#1401](https://github.com/ai-ecoverse/slicc-kernel/issues/1401)), with the program's own certificate and key: `net.listen({ port, host, tls: { cert, key, passphrase, alpn, ca, requestCert, rejectUnauthorized } })` (and `sync.net.listen`) gives a listener whose connections are already secured, and `net.secure(conn, { server: true, cert, key, … })` secures one accepted connection. `net.secure(conn, tls)` without `server: true` is the client upgrade, as `connect({ socket })`.
509
509
  - `cert` and `key` take the same forms as a client certificate, and are refused the same way, at `listen` (or `secure`) before anything is accepted: a key for another certificate is `ERR_OSSL_X509_KEY_VALUES_MISMATCH`, a wrong passphrase `ERR_OSSL_BAD_DECRYPT`.
510
510
  - `alpn` is the server's list in order of preference; the first one the client also offers wins, and a client that offers ALPN but none of these fails the handshake.
511
- - `requestCert` needs `ca` (the roots a client certificate must reach; else `EINVAL`). With it the handshake fails without a client certificate that verifies, unless `rejectUnauthorized: false`, which only asks for one and reports the verdict.
512
- - A connection's `tls` is `{ path: 'kernel', protocol, alpnProtocol, servername (the SNI the client sent, or ''), authorized (whether a client certificate verified), authorizationError?, peerCertificates (the client's chain) }`.
511
+ - `requestCert` asks for a client certificate, checked against `ca` (the roots it must reach) or, without `ca`, the kernel's bundled roots, as Node's default store ([#1503](https://github.com/ai-ecoverse/slicc-kernel/issues/1503)). With it the handshake fails without a client certificate that verifies, unless `rejectUnauthorized: false`, which only asks for one and reports the verdict.
512
+ - A connection's `tls` is `{ path: 'kernel', protocol, cipher ({ name, standardName, version }, as a client's; [#1500](https://github.com/ai-ecoverse/slicc-kernel/issues/1500)), alpnProtocol, servername (the SNI the client sent, or ''), authorized (whether a client certificate verified), authorizationError?, peerCertificates (the client's chain) }`.
513
513
  - On a `listen({ tls })` listener, the kernel handshakes accepted connections concurrently, from the first `accept` on, and `accept` (and `for await`) yields each as its handshake finishes, so a client that connects and stays silent doesn't hold up the next. It holds at most `backlog` (up to 64) connections in a handshake or handshaken but not yet taken; beyond that it stops accepting, leaving the rest in the kernel's backlog, until one finishes or is taken. A connection whose handshake fails is closed and never yielded. `secure()` rejects instead.
514
514
  - `handshakeTimeout` (milliseconds, 120000 by default, as Node's) ends a handshake that hasn't finished with `ERR_TLS_HANDSHAKE_TIMEOUT`. A listener closes that connection. After `secure()` rejects, the connection stays open and is the program's to answer or close, as with Node's `tlsClientError` ([#1504](https://github.com/ai-ecoverse/slicc-kernel/issues/1504)).
515
515
  - A client that sends a plain HTTP request instead of a ClientHello fails the handshake with `ERR_SSL_HTTP_REQUEST`, as in Node. A handshake, server or loopback client, waits without holding the program's other calls, and an aborted `accept({ signal })` or `connect({ signal })` ends it. A signal delivered to the program doesn't interrupt a handshake, as with `SA_RESTART`.
@@ -556,7 +556,7 @@ The module's default export (or its `main` export) is called with a context and
556
556
  - a head with placeholders in two header lines;
557
557
  - later requests on the same connection;
558
558
  - anything once the head passes 16 KiB, or once the program pauses for 2 s in the middle of it;
559
- - **What's read back:** once a process has had a value substituted for a host, everything its whole process tree reads from any A2 session to that host is scrubbed: the process itself, its ancestors and every descendant (forked, spawned or exec'd, before or after the substitution; a forked child's own new session included) with [slicc-scrub](https://github.com/ai-ecoverse/slicc-scrub), as S2 does: the value comes back as its placeholder, and a fragment or an encoded form (JSON, percent, HTML, base64, hex) as `[redacted:sealed]`. The scrub stays until the whole tree has ended. A process outside the tree isn't covered. A value shorter than 8 bytes is never substituted, as it couldn't be scrubbed.
559
+ - **What's read back:** once a process has had a value substituted for a host, everything its whole process tree reads from any A2 session to that host is scrubbed: the process itself, its ancestors and every descendant (forked, spawned or exec'd, before or after the substitution; a forked child's own new session included) with [slicc-scrub](https://github.com/ai-ecoverse/slicc-scrub), in every form S2 matches (the value, a fragment, JSON, percent, HTML, base64, hex, wrapped). Unlike S2, each match comes back as filler of exactly its own length: every byte `*`, CR and LF kept ([#1509](https://github.com/ai-ecoverse/slicc-kernel/issues/1509), slicc-scrub's `sameLength`). So the session's framing still holds where a server reflects a value (an HTTP `Content-Length` or chunk size, a pg message length, a WebSocket frame), and the filler shows only the match's length, which the framing shows anyway, and where a value with line breaks has them. The scrub stays until the whole tree has ended. A process outside the tree isn't covered. A value shorter than 8 bytes is never substituted, as it couldn't be scrubbed.
560
560
  - **The bound:** the server the value goes to is the one the vault rule names, verified against the kernel's own roots. Substitution trusts that server not to reflect the value back in a form the scrub doesn't know (encrypted under a key the program chose, say). That's the same residual as A1's.
561
561
  - **Elsewhere:** a placeholder written into any other A2 session reaches the server as written. It isn't the secret, so nothing leaks.
562
562
  - **Limits:** no revocation checking (neither OCSP nor CRLs, as with Node's default), no session resumption, and the clock is the page's `Date.now()`.
@@ -1099,7 +1099,7 @@ The kernel then asks the hook `{ v: 1, op: 'materializeConsume', ticket, stamp,
1099
1099
  - `interpreters` is the chain of `#!` and `binfmt` interpreters the kernel will really run, outermost first, each absolute with its symlinks resolved, and empty for a program of the catalog (Emscripten, WASI or JS) and the `/usr/bin/<name>` entries for one. A plain `#!/usr/bin/env X` (or `env -S X …`) is reported as `X`: a command of the catalog as `/usr/bin/X`, a script command by its file. `env` with any other option or a `NAME=value` assignment is run and reported as `/usr/bin/env` itself;
1100
1100
  - `trusted` is true when the program and every interpreter are regular files owned by root and not writable by group or others, under directories that are all the same, up to `/` (for a command of the catalog, its package files). It's false unless `users` is `'enforce'`: under `'off'` files have no owners, and under `'report'` owners don't stop a write. The reply must be `{ values, placeholders }` (string maps) within 10 s; anything else is `EACCES`. Each variable of the child's environment whose value is exactly `placeholders[NAME]` gets `values[NAME]`; nothing else changes. The kernel keeps no copy and logs nothing of either. Like any environment, the values are inherited by whatever the child itself spawns or execs, as on Linux. A ticket isn't carried across `sudo`, which builds its own environment: a `sudo` run with `SLICC_SEALED_TICKET` set fails with `EACCES`, and burns the ticket.
1101
1101
 
1102
- **The child's output is scrubbed (S2).** The kernel compiles a [slicc-scrub](https://github.com/ai-ecoverse/slicc-scrub) scrubber (1.0.0, bundled; the published package has no runtime dependency) over exactly the values it swapped in, and gives the child new open file descriptions for fd 1 and fd 2, for every other descriptor it holds on the same descriptions (a `dup` of them), and for every descriptor on a terminal (fd 0 of a terminal session is one), that pass every write through it into the originals (one wrapper per original description, so `2>&1` keeps its order). A value appears as its placeholder, and a fragment or encoded form of it (JSON, percent, HTML, base64, hex, wrapped) as `[redacted:NAME]`. Bytes go through at once unless they may still be the start of a match; such a tail is let out after 50 ms without a write when it's shorter than 16 bytes, and otherwise at the next write or at close. The child's descendants inherit the scrubbed descriptions through fork, spawn, exec and `dup`, and a descendant that opens its controlling terminal (`/dev/tty`, or a terminal by name) gets one scrubbed too, as does a pseudo-terminal slave (`/dev/pts/N`) or [`/dev/clipboard`](#devclipboard) it opens, and a `/dev/clipboard` descriptor it inherited is wrapped at the redeem like fd 1 and 2, so the page's clipboard gets placeholders, never the values. Files, sockets and pipes the child or a descendant opens itself are not scrubbed, so `printf %s "$T" > f` writes the value. A scrubbed fd 1 or 2 is a stream: it doesn't seek. Under `O_NONBLOCK` it gives `EAGAIN` when the pipe or terminal under it would. A write that finds room returns at once, taking at most 64 KiB, and the scrubbed bytes the file couldn't take yet go out before anything written later. Until they have, `poll` reports it unwritable and a nonblocking write gets `EAGAIN`, taking nothing. A blocking write that waits for them, or for room, ends with `EINTR` at a signal, taking nothing; one interrupted after it began writing returns its whole count, and the rest follows. A descendant that redeems a ticket of its own adds its values: its output is scrubbed for both sets. A value under 8 bytes is swapped but can't be scrubbed. The scrubber goes when the last process and description holding it do.
1102
+ **The child's output is scrubbed (S2).** The kernel compiles a [slicc-scrub](https://github.com/ai-ecoverse/slicc-scrub) scrubber (1.1.0, bundled; the published package has no runtime dependency) over exactly the values it swapped in, and gives the child new open file descriptions for fd 1 and fd 2, for every other descriptor it holds on the same descriptions (a `dup` of them), and for every descriptor on a terminal (fd 0 of a terminal session is one), that pass every write through it into the originals (one wrapper per original description, so `2>&1` keeps its order). A value appears as its placeholder, and a fragment or encoded form of it (JSON, percent, HTML, base64, hex, wrapped) as `[redacted:NAME]`. Bytes go through at once unless they may still be the start of a match; such a tail is let out after 50 ms without a write when it's shorter than 16 bytes, and otherwise at the next write or at close. The child's descendants inherit the scrubbed descriptions through fork, spawn, exec and `dup`, and a descendant that opens its controlling terminal (`/dev/tty`, or a terminal by name) gets one scrubbed too, as does a pseudo-terminal slave (`/dev/pts/N`) or [`/dev/clipboard`](#devclipboard) it opens, and a `/dev/clipboard` descriptor it inherited is wrapped at the redeem like fd 1 and 2, so the page's clipboard gets placeholders, never the values. Files, sockets and pipes the child or a descendant opens itself are not scrubbed, so `printf %s "$T" > f` writes the value. A scrubbed fd 1 or 2 is a stream: it doesn't seek. Under `O_NONBLOCK` it gives `EAGAIN` when the pipe or terminal under it would. A write that finds room returns at once, taking at most 64 KiB, and the scrubbed bytes the file couldn't take yet go out before anything written later. Until they have, `poll` reports it unwritable and a nonblocking write gets `EAGAIN`, taking nothing. A blocking write that waits for them, or for room, ends with `EINTR` at a signal, taking nothing; one interrupted after it began writing returns its whole count, and the rest follows. A descendant that redeems a ticket of its own adds its values: its output is scrubbed for both sets. A value under 8 bytes is swapped but can't be scrubbed. The scrubber goes when the last process and description holding it do.
1103
1103
 
1104
1104
  **A2 sessions (`sessionMaterialize`, [#1090](https://github.com/ai-ecoverse/slicc-kernel/issues/1090)).** For a kernel-terminated TLS session (see **Sealed values in an A2 session** under JS programs' `ctx.net`), the kernel asks the hook `{ v: 1, op: 'sessionMaterialize', caller, stamp, session, host, port, protocol, position, verified, handshakeAt, exit, placeholders, head? }` (slicc-vault 1.10.0, Sessions), once per session:
1105
1105
  - `stamp` (also `caller`) is the stamp of the process that owns the socket; `session` the kernel's id for it (16 to 128 of `A-Za-z0-9_-`); `host` the name the leaf was verified against, lower-cased without a trailing dot; `handshakeAt` when the handshake finished (epoch ms); `exit` the exit the session leaves by, as the uplink reported it (`tailscale` or `device:<id>`, the id of `[A-Za-z0-9._:-]`);
@@ -7915,7 +7915,7 @@ function satisfies(version, range2) {
7915
7915
  }
7916
7916
 
7917
7917
  // src/version.ts
7918
- var KERNEL_VERSION = true ? "1.150.0" : "0.0.0-dev";
7918
+ var KERNEL_VERSION = true ? "1.151.0" : "0.0.0-dev";
7919
7919
  var OS_RELEASE = "6.0.0-slicc";
7920
7920
  function procVersion() {
7921
7921
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -18924,8 +18924,10 @@ async function tlsServer(conn, session, clientCerts) {
18924
18924
  await serverHandshake(conn, session, inbox, () => first);
18925
18925
  const chain2 = session.peerChain();
18926
18926
  const code = clientCerts ? verifyCode(session.verifyResult) : void 0;
18927
+ const protocol = session.version === 772 ? "TLSv1.3" : "TLSv1.2";
18927
18928
  const info = {
18928
- protocol: session.version === 772 ? "TLSv1.3" : "TLSv1.2",
18929
+ protocol,
18930
+ cipher: cipherOf(session.ciphersuite, protocol),
18929
18931
  alpnProtocol: session.alpn ?? false,
18930
18932
  servername: session.sni,
18931
18933
  authorized: clientCerts && chain2.length > 0 && code === void 0,
@@ -19619,9 +19621,9 @@ function compile(secrets, options = {}) {
19619
19621
  var SCRUB_IDLE_MS = 50;
19620
19622
  var SCRUB_NONBLOCK_MAX = 64 * 1024;
19621
19623
  var encoder6 = new TextEncoder();
19622
- function scrubberOf(secrets) {
19624
+ function scrubberOf(secrets, sameLength = false) {
19623
19625
  const usable2 = secrets.filter((s) => encoder6.encode(s.value).length >= MIN_VALUE);
19624
- return usable2.length > 0 ? compile(usable2, { idleMs: SCRUB_IDLE_MS }) : void 0;
19626
+ return usable2.length > 0 ? compile(usable2, { idleMs: SCRUB_IDLE_MS, sameLength }) : void 0;
19625
19627
  }
19626
19628
  async function writeAll3(file, bytes) {
19627
19629
  const write = file.write;
@@ -19809,7 +19811,7 @@ function addTo(tree2, host, secrets) {
19809
19811
  function scrubOf(tree2, host) {
19810
19812
  const entry = tree2?.get(host);
19811
19813
  if (!entry) return void 0;
19812
- entry.scrubber ??= scrubberOf(entry.secrets);
19814
+ entry.scrubber ??= scrubberOf(entry.secrets, true);
19813
19815
  return entry.scrubber ? { version: entry.version, scrubber: entry.scrubber } : void 0;
19814
19816
  }
19815
19817
  var SessionScrubs = class {
@@ -22124,9 +22126,8 @@ function storeOf(engine, ca) {
22124
22126
  throw new KernelError("EINVAL", `ERR_INVALID_ARG_VALUE: ca: ${err.message}`);
22125
22127
  }
22126
22128
  }
22127
- function flagsOf(req, store) {
22129
+ function flagsOf(req) {
22128
22130
  if (!req.requestCert) return 0;
22129
- if (!store) throw new KernelError("EINVAL", "ERR_INVALID_ARG_VALUE: requestCert needs a ca");
22130
22131
  return req.rejectUnauthorized === false ? TLS_SERVER_REQUEST_CERT : TLS_SERVER_REQUIRE_CERT;
22131
22132
  }
22132
22133
  function timeoutOf(req) {
@@ -22162,10 +22163,11 @@ async function serverOf(proc, req) {
22162
22163
  throw err;
22163
22164
  }
22164
22165
  const leaf = leafOf(engine, cert);
22165
- let store;
22166
+ let mine;
22166
22167
  try {
22167
- store = req.ca && req.ca.length > 0 ? storeOf(engine, req.ca) : void 0;
22168
- const flags2 = flagsOf(req, store);
22168
+ mine = req.ca && req.ca.length > 0 ? storeOf(engine, req.ca) : void 0;
22169
+ const flags2 = flagsOf(req);
22170
+ const store = mine ?? (flags2 ? await defaultStore(engine, supportOf(proc.net).roots) : void 0);
22169
22171
  return {
22170
22172
  engine,
22171
22173
  leaf,
@@ -22175,12 +22177,12 @@ async function serverOf(proc, req) {
22175
22177
  timeoutMs: timeoutOf(req),
22176
22178
  release() {
22177
22179
  leaf.release();
22178
- store?.release();
22180
+ mine?.release();
22179
22181
  }
22180
22182
  };
22181
22183
  } catch (err) {
22182
22184
  leaf.release();
22183
- store?.release();
22185
+ mine?.release();
22184
22186
  throw err;
22185
22187
  }
22186
22188
  }
@@ -22209,7 +22211,8 @@ async function serveSecure(proc, socket, req) {
22209
22211
  servername: info.servername,
22210
22212
  authorized: info.authorized,
22211
22213
  ...info.authorizationError ? { authorizationError: info.authorizationError } : {},
22212
- peerCertificates: info.peerCertificates.map(encode)
22214
+ peerCertificates: info.peerCertificates.map(encode),
22215
+ cipher: info.cipher
22213
22216
  };
22214
22217
  } finally {
22215
22218
  if (!shared) server.release();