mirror of
https://github.com/mineracks/seedhammer-v1-companion.git
synced 2026-06-26 22:01:05 +10:00
A browser-based companion for SeedHammer v1 hardware. Three coordinated PWAs (composer, emulator, combined-sim) plus an optional Android wrapper, inspired by Gangleri42's SeedHammer II fork but retargeted to v1's Pi-Zero / WaveShare / MarkingWay hardware. What's in this commit: - LICENSE: Unlicense (matching upstream) - README.md: project overview + status + roadmap - CREDITS.md: upstream provenance + pinned baseline SHAs - docs/architecture/: five authoritative design docs (lifted from the prep work in mineracks-infrastructure): * BASELINES.md — pinned SHAs, license audit, path-mapping table * v1-engrave-spec.md — MarkingWay USB-serial wire protocol audit * v1-buttons-and-ui.md — GPIO map, UI screen flow, keyboard map * sh1e-spec.md — composer-to-Pi envelope format spec * seedsigner-reuse.md — Pyodide strategy + jumbo (SeedSigner+) support - Go package skeleton with doc.go contracts: backup, bezier, bspline, engrave, engrave/wire, engrave/wire/sh1e, font (+ comfortaa, poppins, constant), gui, input, internal/golden, platform/v1 - cmd/ entrypoints with stub main(): composer, emulator, combined-sim, seedsigner-sim - web/ static-shell skeleton - go.mod (module github.com/mineracks/seedhammer-v1-companion, Go 1.22) - go build ./... + go vet ./... both clean Next: lift universal packages (backup, font, bezier, bspline) from upstream seedhammer/seedhammer at v1.3.0 verbatim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
356 lines
14 KiB
Markdown
356 lines
14 KiB
Markdown
# SH1E — SeedHammer v1 Engrave envelope format
|
||
|
||
**Status:** draft v0.1, 2026-05-28
|
||
**Stability:** unstable — the format will change as we validate it against
|
||
real hardware. Pin the version byte; any breaking change bumps it.
|
||
|
||
SH1E is the on-the-wire format that ships a plate design from the
|
||
browser-side composer to a SeedHammer v1 Pi controller, intended primarily
|
||
for transport via QR code(s) scanned by the Pi's camera. Secondary
|
||
transport channels (USB stick, HTTP) work without modification.
|
||
|
||
The Pi side parses SH1E, runs the parsed design through its **existing
|
||
trusted rasteriser** (`engrave/` + `font/` + `backup/` packages), shows
|
||
the user a preview on the LCD, and waits for hold-to-confirm before
|
||
streaming the resulting 10-byte commands to the engraver via `mjolnir/`.
|
||
|
||
The composer side emits SH1E for transport AND uses the same Go code in
|
||
WASM to render an in-browser preview that matches what the Pi will produce
|
||
**by code-identity**, not by re-implementation. See the [project plan
|
||
doc](./BASELINES.md) for the broader architecture.
|
||
|
||
## Design goals
|
||
|
||
1. **Intent, not commands.** SH1E describes what to engrave, not how. The
|
||
trusted Pi controller rasterises locally; the composer cannot directly
|
||
drive the engraver bytes. This keeps the security boundary in the right
|
||
place.
|
||
2. **Single-frame QR friendly.** A 24-word plate fits in one QR (≤ ~1100
|
||
bytes for QR version 24 with low EC). Larger designs degrade to
|
||
multi-frame BBQr or animated QR.
|
||
3. **Self-describing.** Magic + version + length + CRC32 — any reader can
|
||
detect "this is SH1E", reject the wrong version cleanly, and detect
|
||
corruption from bit-flips in the QR.
|
||
4. **Canonical encoding.** Same design → same bytes → same QR. Users who
|
||
want to verify "the QR I'm scanning is the design I intended" can
|
||
reproduce it.
|
||
5. **Easy to parse safely.** Deterministic CBOR with strict ranges. No
|
||
recursive structures, no length-prefix tricks, no implicit defaults
|
||
that could be spoofed.
|
||
|
||
## Envelope
|
||
|
||
```
|
||
+----------+----------+--------------+--------+-----------+
|
||
| magic | version | payload_len | crc32 | payload |
|
||
| 4 bytes | 1 byte | 2 bytes (LE) | 4 bytes| N bytes |
|
||
| "SH1E" | 0x01 | 0x0000- | | |
|
||
| | | 0xFFFF | | |
|
||
+----------+----------+--------------+--------+-----------+
|
||
```
|
||
|
||
- **`magic`** = ASCII `SH1E` (0x53 0x48 0x31 0x45). Constant.
|
||
- **`version`** = current `0x01`. Any other value rejected by readers.
|
||
- **`payload_len`** = exact byte count of `payload`, little-endian uint16.
|
||
Max 65535 bytes. (Reality budget: a single-frame QR fits ~1500 bytes
|
||
of binary data; we'd never approach the uint16 ceiling.)
|
||
- **`crc32`** = CRC-32/ISO-HDLC (poly `0xEDB88320`, init `0xFFFFFFFF`,
|
||
refin/refout true, xorout `0xFFFFFFFF`) computed over the `payload`
|
||
bytes only (NOT magic/version/length). Little-endian uint32.
|
||
- **`payload`** = CBOR-encoded `Design` (see below). RFC 8949 deterministic
|
||
encoding only.
|
||
|
||
Total minimum overhead: **11 bytes** of envelope.
|
||
|
||
## Payload structure (Design)
|
||
|
||
The `Design` is a CBOR map. All fields are required unless marked
|
||
optional. Top-level keys are short integers (deterministic encoding rule:
|
||
integer keys sort before string keys; integer keys are encoded in their
|
||
smallest form).
|
||
|
||
```cbor
|
||
{
|
||
1: <uint> # plate_type — see "Plate types" below
|
||
2: [<TextBlock>], # text_blocks (array, len 0..32)
|
||
3: [<SvgPath>], # svg_paths (array, len 0..16, OPTIONAL — omit if empty)
|
||
4: <bytes>(32) # design_fingerprint — SHA-256 of canonical-encoded fields 1-3, used as a stable design ID
|
||
}
|
||
```
|
||
|
||
### Plate types (integer enum, matches `backup/backup.go` order)
|
||
|
||
| Value | Name | Dimensions (mm) | Source |
|
||
|---:|---|---|---|
|
||
| 0 | `SmallPlate` | 85 × 55 | `backup.SmallPlate` |
|
||
| 1 | `SquarePlate` | 85 × 85 | `backup.SquarePlate` |
|
||
| 2 | `LargePlate` | 85 × 134 | `backup.LargePlate` |
|
||
|
||
### TextBlock
|
||
|
||
```cbor
|
||
{
|
||
1: <uint> # font_id — see "Font IDs"
|
||
2: <uint> # size — point size, 1..200 (clamped Pi-side)
|
||
3: <int> # x_mm — millimetres from plate origin, signed int (origin offset handled Pi-side)
|
||
4: <int> # y_mm — millimetres from plate origin
|
||
5: <uint> # alignment — 0 Left | 1 Center | 2 Right
|
||
6: <tstr> # text — UTF-8, but Pi-side limited to ASCII (rejects non-ASCII at parse)
|
||
7: <uint> # rotation — 0, 90, 180, 270 degrees only (OPTIONAL, default 0)
|
||
}
|
||
```
|
||
|
||
**Text length cap:** 256 chars per block (UTF-8 bytes, before ASCII check).
|
||
**Combined design cap:** 32 text blocks max.
|
||
|
||
### Font IDs (matches upstream v1 fonts at the pinned baseline)
|
||
|
||
| Value | Name | Source path |
|
||
|---:|---|---|
|
||
| 0 | `Comfortaa` | `font/comfortaa` |
|
||
| 1 | `Poppins` | `font/poppins` |
|
||
| 2 | `Constant` | `font/constant` |
|
||
|
||
If we lift additional fonts in a later baseline, the enum extends but
|
||
**never renumbers**.
|
||
|
||
### SvgPath
|
||
|
||
For logos, custom marks, embedded designs. The Pi parses these to its
|
||
internal segment representation (`MoveTo` / `LineTo` / `QuadTo` /
|
||
`CubeTo`) — same shape as the font glyph code already handles.
|
||
|
||
```cbor
|
||
{
|
||
1: <int> # x_mm — anchor X
|
||
2: <int> # y_mm — anchor Y
|
||
3: <uint> # scale_pct — percentage 1..1000 (clamped Pi-side)
|
||
4: <tstr> # path_d — SVG `d` attribute string, validated against a strict subset
|
||
5: <uint> # rotation — 0/90/180/270 (OPTIONAL, default 0)
|
||
}
|
||
```
|
||
|
||
**SVG path subset (strict):**
|
||
- Commands allowed: `M`, `L`, `H`, `V`, `Q`, `C`, `Z` (and lowercase relatives `m`, `l`, `h`, `v`, `q`, `c`, `z`)
|
||
- No arcs (`A`/`a`) — they require curve approximation that's easier to do composer-side
|
||
- No `S`/`T` shortcuts — composer must emit explicit `Q`/`C` instead
|
||
- Coordinates: decimal numbers with optional leading sign, no scientific
|
||
notation, no comma separators, single-space-separated. (Stricter than
|
||
full SVG but trivially parseable in Go.)
|
||
- Max path length: 4096 chars
|
||
- Max number of points: 1024
|
||
|
||
## Canonical encoding rules
|
||
|
||
Per RFC 8949 § 4.2.1, with these additional clarifications:
|
||
|
||
1. Map keys sorted by integer value ascending.
|
||
2. Integers in their shortest CBOR form (tiny ints inline, then uint8,
|
||
uint16, uint32, uint64 as needed).
|
||
3. Text strings encoded as UTF-8 with no BOM.
|
||
4. Byte strings have explicit length prefix.
|
||
5. No indefinite-length items.
|
||
6. No tags (no CBOR semantic tags used in this spec).
|
||
7. Floating point not used anywhere (all numerics are integers).
|
||
|
||
**Two encodings of the same Design MUST produce byte-identical payload.**
|
||
This is enforceable by the composer using a known-canonical CBOR library
|
||
(e.g. `fxamacker/cbor` with `EncOptions{Sort: SortBytewiseLexical}`).
|
||
|
||
## QR transport
|
||
|
||
A single SH1E envelope:
|
||
|
||
| Design complexity | Approx envelope size | Recommended QR |
|
||
|---|---:|---|
|
||
| 12-word seed, SmallPlate, single font block | ~150 B | QR version 6, M ECC, alphanumeric mode |
|
||
| 24-word seed, LargePlate, 2 font blocks | ~280 B | QR version 10, M ECC, byte mode |
|
||
| 24-word seed + multisig descriptor, LargePlate, 4 font blocks | ~500 B | QR version 17, M ECC, byte mode |
|
||
| Above + small SVG logo | ~700 B | QR version 22, M ECC |
|
||
| Multi-plate manifest (3 plates) | 1.5-2 KB | **BBQr or animated QR** |
|
||
|
||
For multi-frame transport, use **BBQr** (https://github.com/coinkite/BBQr)
|
||
as it's already supported by ColdCard / Sparrow / Specter and we'd get
|
||
ecosystem compatibility for free. Don't invent a new chunking format.
|
||
|
||
The Pi controller scans BBQr or single QR transparently — the camera
|
||
flow on v1 already understands both via its existing QR reader.
|
||
|
||
## Examples
|
||
|
||
### Minimal: 12-word seed on SmallPlate, single font block
|
||
|
||
```json5
|
||
{
|
||
1: 0, // plate_type = SmallPlate
|
||
2: [
|
||
{
|
||
1: 0, // font_id = Comfortaa
|
||
2: 12, // size = 12pt
|
||
3: 5, // x_mm
|
||
4: 5, // y_mm
|
||
5: 0, // alignment = Left
|
||
6: "ABANDON ABILITY ABLE ABOUT ABOVE ABSENT ABSORB ABSTRACT ABSURD ABUSE ACCESS ACCIDENT"
|
||
}
|
||
],
|
||
4: <32-byte SHA-256>
|
||
}
|
||
```
|
||
|
||
CBOR encoding of this: approximately 130 bytes (depends on the exact
|
||
seed words used).
|
||
|
||
### Two-block layout with logo
|
||
|
||
```json5
|
||
{
|
||
1: 1, // plate_type = SquarePlate
|
||
2: [
|
||
{ // title
|
||
1: 1, // font_id = Poppins
|
||
2: 18,
|
||
3: 10, 4: 8,
|
||
5: 1, // Center
|
||
6: "MY MULTISIG KEY 1 OF 3"
|
||
},
|
||
{ // seed words
|
||
1: 0, // font_id = Comfortaa
|
||
2: 12,
|
||
3: 5, 4: 25,
|
||
5: 0,
|
||
6: "WORD1 WORD2 WORD3 ... WORD24"
|
||
}
|
||
],
|
||
3: [
|
||
{ // BTC logo top-right
|
||
1: 70, 2: 5,
|
||
3: 100, // 100% scale
|
||
4: "M 0 0 L 10 0 L 10 10 L 0 10 Z M 2 2 L 8 8 M 8 2 L 2 8"
|
||
}
|
||
],
|
||
4: <32-byte SHA-256>
|
||
}
|
||
```
|
||
|
||
## Parser validation rules (Pi side)
|
||
|
||
The Pi-side parser **must** reject a SH1E envelope if any of these hold,
|
||
without further processing:
|
||
|
||
1. `magic` ≠ `SH1E`
|
||
2. `version` ≠ `0x01`
|
||
3. `payload_len` doesn't match actual payload byte length
|
||
4. `crc32` doesn't match recomputed CRC
|
||
5. Payload is not canonical CBOR (sort order wrong, indefinite-length used,
|
||
floats present, unknown tag present)
|
||
6. Required key missing (1, 2, or 4 absent)
|
||
7. Any unknown integer key in any map (forwards-compat: unknown string
|
||
keys allowed and ignored, unknown integer keys MUST be rejected — this
|
||
reserves int keys for future-version expansion)
|
||
8. `plate_type` not in `{0, 1, 2}`
|
||
9. `font_id` references a font not bundled in the running firmware
|
||
10. `text` contains a non-ASCII codepoint (because v1 fonts are ASCII-only)
|
||
11. Any numeric field outside its documented range
|
||
12. Total text block count > 32 or any text length > 256 bytes
|
||
13. Total SVG path count > 16 or any path string > 4096 chars
|
||
14. `design_fingerprint` doesn't match a recomputed SHA-256 of fields 1-3
|
||
15. Resulting plate layout exceeds plate dimensions (with the
|
||
`outerMargin = 3 mm` from `backup.go` applied)
|
||
16. Total command stream would exceed the engraver's safe operating envelope
|
||
|
||
After validation passes, the Pi:
|
||
- Renders a preview on the LCD using the existing rasterisation pipeline
|
||
- Shows total command count + estimated engrave time
|
||
- Waits for hold-to-confirm on Button3 (the existing engrave-confirm pattern)
|
||
- Streams the resulting commands via `mjolnir/`
|
||
|
||
## Forward compatibility
|
||
|
||
Two extension dimensions:
|
||
|
||
1. **Bumping version byte** (`0x01 → 0x02`) — used for breaking changes
|
||
(e.g. coordinate system change, new envelope structure). Old firmware
|
||
rejects, prompting user to update.
|
||
2. **Adding new integer keys** to the maps — used for non-breaking
|
||
additions (e.g. new optional `kerning` field on `TextBlock`). Old
|
||
firmware **rejects** unknown integer keys (per rule 7 above) so a
|
||
composer using v0.2 keys can target v0.1 readers only by omitting them.
|
||
3. **String keys** are reserved for **forward-compat hints** — old readers
|
||
ignore them. Use sparingly and never for security-relevant data (since
|
||
they're ignored).
|
||
|
||
## Security analysis
|
||
|
||
### Threat model
|
||
|
||
The Pi controller is the trusted compute base. The composer is untrusted —
|
||
could be malicious, could have bugs, could be served from a tampered CDN.
|
||
|
||
### What SH1E protects against
|
||
|
||
- **Wrong content engraved silently:** the Pi rasterises locally and shows
|
||
a preview the user must confirm. A malicious composer cannot bypass.
|
||
- **Out-of-range coordinates damaging the machine:** parser rule 15 +
|
||
range checks on every numeric.
|
||
- **Buffer-overflow style attacks:** strict size caps on all variable-length
|
||
fields, CBOR parser used must be vetted for buffer safety.
|
||
- **Confused-deputy via SVG complexity:** strict subset, hard caps on
|
||
path string length and point count.
|
||
- **Bit-flip corruption in QR:** CRC32 catches single-byte flips with
|
||
~99.9999998% probability.
|
||
|
||
### What SH1E does NOT protect against
|
||
|
||
- **Social engineering** to engrave the wrong seed. The Pi preview is
|
||
the user's last line of defence. They must read it carefully.
|
||
- **Pre-image or collision attacks on `design_fingerprint`.** It's
|
||
SHA-256 over canonical bytes — strong enough that any practical
|
||
collision is computationally infeasible.
|
||
- **Malicious upstream firmware.** Out of scope — that's a wider problem.
|
||
|
||
### Fuzzing requirement
|
||
|
||
Before any release ships with SH1E support, the Pi-side parser MUST be
|
||
fuzz-tested. Recommend `go-fuzz` or Go 1.18+ native fuzzing for at least
|
||
1 CPU-week against the parser entry point.
|
||
|
||
## Open questions
|
||
|
||
1. **Should we use `engrave/wire/`-style envelope for binary symmetry with
|
||
SH2E?** Currently no — SH2E is a *post-rasterised command stream*, SH1E
|
||
is a *design intent*. They serve different purposes. But the magic
|
||
prefix `SH1E` parallels `SH2E` deliberately.
|
||
2. **Should the Pi sign acknowledgement of a successful engrave back via
|
||
QR?** Useful for multi-plate flows (composer can verify which plate
|
||
was actually written). Out of scope for v0.1 — add in v0.2 if there's
|
||
user demand.
|
||
3. **What about hardware-bound designs?** A user could optionally embed
|
||
the iDRAC/serial number of their specific SeedHammer v1 in the design
|
||
to prevent "wrong physical device played the wrong design". Probably
|
||
overkill. Out of scope for v0.1.
|
||
4. **Should we support custom fonts uploaded as part of the design?** No.
|
||
That dramatically expands the attack surface (font parsers are
|
||
notoriously bug-prone) and breaks the "rasterise with trusted Pi
|
||
code" property. If users want a new font, propose upstream and we
|
||
ship it in firmware.
|
||
|
||
## Implementation references
|
||
|
||
- CBOR library (Pi side, Go): `github.com/fxamacker/cbor/v2` — already
|
||
used elsewhere in the seedhammer codebase
|
||
- CBOR library (composer side, Go-WASM): same package, compiled to WASM
|
||
- CRC32 (Go): `hash/crc32.MakeTable(crc32.IEEE)` — standard library
|
||
- BBQr (Go): port from `github.com/coinkite/BBQr` Python reference, or
|
||
use an existing Go BBQr lib if one exists at implementation time
|
||
|
||
## Sources
|
||
|
||
- Upstream `backup/backup.go` for plate dimensions (pinned in
|
||
[BASELINES.md](./BASELINES.md))
|
||
- Upstream `mjolnir/driver.go` for engraver wire details (see
|
||
[v1-engrave-spec.md](./v1-engrave-spec.md))
|
||
- RFC 8949 (CBOR)
|
||
- RFC 1952 (CRC-32 variant matches; see also CRC catalogue at
|
||
reveng.sourceforge.io for `CRC-32/ISO-HDLC`)
|
||
- BBQr spec at https://github.com/coinkite/BBQr
|