Skip to content

Security posture ​

The rules the shell keeps when it runs untrusted code next to the user's identity. Each rule has a scenario in packages/*/features/ or a check in scripts/; a rule with neither is a wish.

Mount rules ​

  1. srcdoc, never src. The applet document is the verified bytes with the shell prelude and CSP injected outside the signed content. A src URL would give the applet an origin and a network path the shell does not mediate.
  2. sandbox="allow-scripts" only. mountApplet sets the attribute to exactly allow-scripts, replacing any tokens the host set. Never allow-same-origin, never allow-top-navigation, never allow-popups. The frame has an opaque origin and cannot reach the shell's storage or DOM.
  3. The guard's CSP, unchanged by profile. createAppletCsp(profile) is applied through prepareNappletSrcdoc, with experimental.nestedContextLock on, so kehto adds require-trusted-types-for 'script'; trusted-types default to the CSP meta it renders; createAppletCsp returns the same policy for every profile. No grant, profile or setting alters it. See Guard.
  4. No controllers. On the attached, still empty frame, before any applet document exists, mountApplet calls kehto's denyNappletControllerAccess(frame), which rewrites the frame's allow attribute (its Permissions Policy) to end in gamepad 'none'; hid 'none'; usb 'none' and drops any entry the host had set for those three features. A frame without getAttribute gets the same value as CONTROLLER_DENY_POLICY. This rule is about game controllers and USB or HID devices only. Workers, service workers included, are refused by the CSP's worker-src 'none' (rule 3), not by this policy.
  5. Order: attached empty frame → harden → register → set environment → observe frame → assign srcdoc. The host attaches the iframe to the document first, with no src or srcdoc (mountApplet throws invalid:frame-detached without a contentWindow). Then mountApplet sets sandbox and the controller policy, originRegistry.register(contentWindow, windowId, identity), then originRegistry.setEnvironment(contentWindow, environment), then bridge.observeFrame(frame, windowId), then frame.srcdoc = plan.srcdoc. Assigning srcdoc first would let the document's first message arrive before the shell knows who it is.
  6. The host page forbids frame navigation. The HTML the server sends carries KERNL_FRAME_POLICY_META, kehto's <meta http-equiv="Content-Security-Policy" content="frame-src 'none'">, in its <head> before any script runs, as apps/web/src/pages/app/index.astro does. Applet frames still load, because their bytes arrive as srcdoc; a frame that tries to load or navigate to a URL is blocked, so an applet cannot replace its verified document before kehto sees its first load. kehto warns in the console when the policy is missing. See Embed kernl core.
  7. Remove the iframe on every revocation reason. window-message, second-hello, invalid-hello, second-load, port-closed and host all end with the frame gone and the guard's state for the window forgotten. A revoked window's identity is never re-bound to the same frame.
  8. No frame in the applet's markup. planMount calls kehto's assertNappletHasNoFrame on the verified bytes before they become a srcdoc, and refuses markup containing an iframe, frame, frameset, object, embed, portal or fencedframe element with denied:nested-frame. kehto parses with the host's DOMParser when there is one and otherwise matches lexically, which may over-reject (D-014).
  9. The frame lock runs before applet code. planMount passes experimental: { nestedContextLock: true } to prepareNappletSrcdoc (@kehto/shell 0.30.0). kehto's prelude then installs the Trusted Types default policy, which refuses frame markup at every HTML string sink, and pins createElement, createElementNS and createDocument so they refuse a frame tag (TypeError: kehto: napplets may not create nested browsing contexts). Because frames can also be minted without a factory, planMount inserts kernl's renderInsertionLockScript() immediately after kehto's prelude: it pins every DOM insertion method to refuse a node that is or contains a frame element (TypeError: denied:nested-frame), refuses custom elements that extend frames, and removes XSLTProcessor, so a frame never becomes connected and never gets a realm. When kehto's CSP meta and prelude are not where planMount expects them, the mount is refused with unavailable:frame-lock rather than run without the lock. The insertion lock stays until kehto guards insertion too (D-014).

Identity rules ​

  1. Verified bytes come from @kehto/nip/5d resolveNapplet. Gateways and caches are accelerators; the hash is the identity. Cached bytes are re-verified on every read.
  2. Identity is (dTag, artifactHash) plus one port. Grants are keyed by that identity; a new build is a new identity with no grants.
  3. Restrictive by narrowing. resolveEnvironment returns only granted domains; kehto intersects, so the hook can only remove. The runtime's permissive default is never reached for an ungranted domain.

Key rules ​

  1. Keys live only inside applesauce signer instances. ExtensionSigner, NostrConnectSigner, PasswordAccount's wrapped signer. kernl never holds raw key bytes and never serialises a private key outside PasswordAccount.
  2. The shell never gives an applet window.nostr. kehto's prelude installs window.napplet and nothing that signs; the shell does not forward the page's signer, and every signature an applet gets goes through rule 15. kernl does not control browser extensions: one that injects scripts into every frame can still reach into an applet's frame and expose its own signing object there, which no rule on this page governs. The guarantee is about what the shell provides, not about what the user's extensions add.
  3. Destructive signatures wait for the user. An applet can ask for a signature only through a granted domain. When an applet window asks the shell to sign a destructive kind (every replaceable kind: 0, 3 and 10000–19999; every addressable kind, 30000–39999; a deletion, kind 5; a request to vanish, kind 62), @kernl/core's consent gate looks up the version it replaces, in the event store and then over the outbox and discovery relays for up to CURRENT_VERSION_TIMEOUT_MS (4 s), shows the change as a semantic diff through host.presentConsent and waits. When no current version is found, the summary says so first ("Current version: could not be loaded; this replaces it entirely") rather than reading as a harmless first version; a denial fails with denied:consent and nothing is signed or published. Both routes pass the gate: relay.publish through the signer kernl returns from ShellAdapter.auth.getSigner(windowId), and outbox.publish in kernl's outbox service wrapper, because kehto's outbox service signs without naming the window. The shell's own signing, with no window, is not gated. Routine kinds are signed without a prompt, and answers are not remembered. kehto's runtime has no such step; this rule is kernl's.

Network rules ​

  1. No applet network path exists outside the shell. All bytes arrive through the resource domain and a bridge. An applet frame has no network of its own, and cannot make a child frame that would have one: kehto's prelude pins every RTC* global (and the webkit/moz variants) to undefined, read-only and non-configurable; rules 8 and 9 stop the applet from creating a child realm where those globals still work; and the CSP refuses fetch, WebSocket, images, frame loads and beacons. tests/e2e/applet-network-sealed.spec.ts proves this in a real browser: 15 frame-creation routes throw, no child realm and no peer-connection constructor exists, and no srflx or relay candidate is gathered. When the host page can reach a public STUN server, the spec asserts that it does and that the applet produces no such candidate; otherwise that control is skipped. The HTTP bridge enforces https only, 10 MiB, 20 s and a private-address block over IPv4 and fully parsed IPv6 literals (Bridges). In Node and on servers it follows up to 3 redirects by hand and re-checks every hop; in a browser a redirect is opaque to it and is refused with denied:redirect-unverifiable. Hostnames are checked as literals only: no resolver is wired, so a public name that resolves to a private address is not caught. Browser fetches are also subject to CORS: a request the browser blocks is unavailable:network Failed to fetch, and any other status-0 answer is unavailable:network <type> response.
  2. One RelayPool. No service, provider or bridge opens its own connection. Adapters over the shared pool satisfy kehto's contracts.
  3. Hidden windows cannot act, but stay controllable. The visibility gate holds only a hidden window's window-level hello. Port traffic is judged just before kehto dispatches it; it cannot be held there (D-012), so while a window is not visible every non-control envelope is refused for that one dispatch (refuseCurrentDispatch) and the applet gets a firewall: error it can retry once visible. Control envelopes (shell.*, inc.*, theme.*, closes) are answered, so a hidden window can still be closed or themed. A resource.* request past the hidden allowance quarantines the window, and that request is not dispatched.

Log rules ​

  1. Secrets never appear in logs, guard events or consent summaries. GuardEvent.reason is a stable prefixed string; NappletRevocation.detail never includes message payloads; describeChange output names fields, not key material.
  2. Guard events are shown to the user, with the applet and the reason. They are not sent anywhere.

Dependency rules ​

  1. nostr-tools is never imported by kernl source. It is a direct peer dependency of @kernl/core, because kehto and applesauce require it, but no file under packages/*/src imports it.
  2. Published packages have zero framework dependencies. svelte, react, vue, preact, solid-js and lit are forbidden in every packages/*/package.json.
  3. Leaf packages do not import @kehto/*, except @kernl/guard, which imports @kehto/firewall. Only @kernl/core composes kehto.

Rules 21 to 23 are enforced by scripts/check-dependency-rules.mjs (pnpm check:deps). Rules 1 to 20 are scenarios in packages/*/features/ (the root features/support/ only wires their step definitions into vitest), and the mount rules are additionally exercised in the browser by the Playwright suite.

Reporting ​

A posture violation is a bug of the highest severity in this repository, above any feature. Report it to the maintainers with the rule number; the source repository is private during the alpha. If it involves a kehto seam, the issue is filed upstream too, and DECISIONS.md records the gap.

MIT licensed. Protocol authority: NIP-5D (nostr-protocol/nips PR #2303) and the NAPs (napplet/naps).