Sapwood Architecture

July 14, 2026 ยท View on GitHub

This component is part of the ForgeSworn Identity Stack. See the ecosystem overview for how it connects to the other components.

Sapwood is a browser-based management UI for Heartwood signing devices. It provisions master identities, manages client policies, uploads firmware, and monitors logs. Connects via Web Serial (direct USB) or HTTP (bridge). Static single-page app, zero server-side dependencies.

Dual transport abstraction

UI components don't know which transport is active. Both emit the same Frame events into a shared reactive state store.

graph TB
    subgraph "UI Components (Svelte 5)"
        ML["MasterList"]
        CL["ClientList"]
        PR["Provision"]
        OTA["OtaUpdate"]
        LOG["LogMonitor"]
    end

    STATE["device.svelte.ts<br/>(Svelte 5 runes)"]

    ML --> STATE
    CL --> STATE
    PR --> STATE
    OTA --> STATE
    LOG --> STATE

    STATE --> SER["Serial Transport<br/>Web Serial API"]
    STATE --> HTTP["HTTP Transport<br/>REST + WebSocket"]

    SER -->|"Binary frames"| ESP["ESP32 Device"]
    HTTP -->|"JSON + Bearer token"| BRIDGE["Heartwood Bridge"]
    BRIDGE -->|"Serial"| ESP

    style ML fill:#3b82f6,color:#fff
    style CL fill:#3b82f6,color:#fff
    style PR fill:#3b82f6,color:#fff
    style OTA fill:#3b82f6,color:#fff
    style LOG fill:#3b82f6,color:#fff
    style STATE fill:#1e293b,color:#e2e8f0
    style SER fill:#f59e0b,color:#000
    style HTTP fill:#8b5cf6,color:#fff
    style ESP fill:#ef4444,color:#fff
    style BRIDGE fill:#8b5cf6,color:#fff

Serial transport talks directly to the ESP32's USB-Serial-JTAG interface (VID: 0x303a, PID: 0x1001) at 115,200 baud. It hunts for magic bytes 0x48 0x57 to separate binary protocol frames from ESP-IDF log output.

HTTP transport talks to the Heartwood bridge running on the Pi. REST API for commands, WebSocket for log streaming. Bearer token auth (injected by bridge). HTTP responses are wrapped as synthetic Frame objects so the state store processes them identically.

Frame protocol

TypeScript port of heartwood-esp32/common/src/frame.rs. 19 tests verify byte-level compatibility.

Wire format:

[0x48 0x57] [type: u8] [length: u16 BE] [payload: 0..32768] [crc32: u32 BE]
FrameCodeDirectionPayload
PROVISION_LIST0x05host to device(empty)
PROVISION_LIST_RESPONSE0x07device to hostJSON: master slots
ACK0x06device to host(empty)
NACK0x15device to host(empty)
FACTORY_RESET0x24host to device(empty, button required)
POLICY_LIST_REQUEST0x27host to devicemaster_slot (1 byte)
POLICY_LIST_RESPONSE0x28device to hostJSON: client policies
POLICY_REVOKE0x29host to devicemaster_slot + pubkey_hex
POLICY_UPDATE0x2Ahost to devicemaster_slot + JSON policy
OTA_BEGIN0x30host to devicesize (u32 BE) + SHA-256 hash (button required)
OTA_CHUNK0x31host to deviceoffset (u32 BE) + binary data
OTA_FINISH0x32host to device(empty)
OTA_STATUS0x33device to hoststatus byte
CONNSLOT_LIST_RESP0x43device to hostJSON: connection slots (HTTP bridge only; serial path pending)

CRC32 uses IEEE 802.3 polynomial, covering type + length + payload (not magic bytes).

OTA firmware update

Firmware updates run over USB with SHA-256 verification and physical button confirmation.

sequenceDiagram
    actor U as User
    box rgb(59, 130, 246) Browser
        participant S as Sapwood
    end
    box rgb(239, 68, 68) Hardware
        participant D as ESP32
    end

    U->>S: Select .bin firmware file
    S->>S: Compute SHA-256 in browser
    S->>D: OTA_BEGIN (file size + hash)
    Note over D: Physical button required
    D-->>S: OTA_STATUS (ready)

    loop Every 4 KB chunk
        S->>D: OTA_CHUNK (offset + data)
        D-->>S: OTA_STATUS (chunk_ok)
    end

    S->>D: OTA_FINISH
    Note over D: Verify SHA-256
    D-->>S: OTA_STATUS (verified)
    Note over D: Reboot with new firmware

Over HTTP, OTA uses a single streaming POST /api/device/ota instead of the frame-by-frame protocol. The bridge handles chunking and verification internally.

Provisioning

Three modes for establishing a master identity on the device:

ModeInputDerivationSecret sent
Tree (mnemonic)12/24-word BIP-39BIP-32 at m/44'/1237'/727'/0'/0'32-byte derived root
Tree (nsec)Existing nsecHMAC-SHA256(nsec, "nsec-tree-root")32-byte derived root
BunkerExisting nsecNone (raw)32-byte nsec

Secrets are zeroised in browser memory immediately after transmission.

Security model

What leaves the device: public keys, policy metadata, signatures, log output.

What stays on the device: master secrets, derived private keys, PIN, bridge secret.

Physical button required for: provisioning, factory reset, OTA begin, bridge secret change.

A compromised Sapwood SPA cannot extract keys, sign arbitrary events, trigger factory reset, or upload firmware. The attack surface is local only -- an attacker must have physical access to press the button.

Integration points

  • Heartwood: The device Sapwood manages. Sapwood provisions master identities and manages client policies on the ESP32 running Heartwood firmware.
  • nsec-tree: Sapwood uses the same derivation scheme for provisioning (mnemonic and nsec modes). The TypeScript nsec-tree library computes the derived root in-browser before sending to the device.
  • ForgeSworn Identity Stack: Sapwood is the device management layer of the signing stack.