seedhammer-v1-companion/backup/backup.go
mineracks 9261cf368a Lift composer substrate from upstream v1.3.0
Twelve packages lifted from seedhammer/seedhammer @ v1.3.0
(commit 2f071c1d8f23eb7fd39b15fc0acb8874113f801e):

  address/     — bitcoin address parsing
  backup/      — v1 plate dimensions + UR-coded multi-plate backup
  bc/          — Blockchain Commons: ur, fountain, bytewords, urtypes,
                 xoshiro256 (5 subpkgs)
  bip32/       — HD-key derivation
  bip39/       — mnemonic seed phrases + 2048-word wordlist
  engrave/     — text/QR → MoveTo/LineTo command stream conversion
  seedqr/      — SeedQR / CompactSeedQR encoders
  image/       — paletted, rgb565, alpha4, ninepatch image formats
  nonstandard/ — bitcoin descriptor + script parsing
  font/        — bitmap + vector font runtime
  font/{comfortaa,poppins,constant,bitmap,vector}/  — actual fonts
  driver/mjolnir/  — MarkingWay USB-serial engraver driver

Plus an earlier-aside backup_test.go restored (its deps are now lifted).

Import paths globally rewritten seedhammer.com → mineracks namespace
via single sed pass; verified no orphan refs remain. go.mod adopts
upstream's full dep set plus the replace-directive for the patched
kortschak/qr fork.

  go build ./...     clean (all 27 packages)
  go test ./...      clean (12 packages with tests, all passing)

NOT lifted in this commit:
  - driver/{wshat,drm,libcamera}  (hardware-specific GPIO/LCD/camera —
    will be platform-v1/-shaped abstractions instead)
  - gui/                (depends on the above; lifts in Phase 2)
  - cmd/{controller,...} (Pi binary entrypoints — not needed for the
    companion repo)
  - zbar/               (QR scanner — needs libcamera)

Next:
  - Write the SH1E reference encoder/decoder in engrave/wire/sh1e/
  - Lift Gangleri42's cmd/webnfc/ shell + retune to v1 plates
  - First buildable composer WASM with a working preview

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:36:40 +10:00

421 lines
12 KiB
Go

// package backup implements the SeedHammer backup scheme.
package backup
import (
"errors"
"fmt"
"image"
"math"
"math/bits"
"reflect"
"strings"
"github.com/kortschak/qr"
"github.com/mineracks/seedhammer-v1-companion/bc/fountain"
"github.com/mineracks/seedhammer-v1-companion/bc/ur"
"github.com/mineracks/seedhammer-v1-companion/bc/urtypes"
"github.com/mineracks/seedhammer-v1-companion/bip39"
"github.com/mineracks/seedhammer-v1-companion/engrave"
"github.com/mineracks/seedhammer-v1-companion/font/vector"
"github.com/mineracks/seedhammer-v1-companion/seedqr"
)
type PlateSize int
const (
SquarePlate PlateSize = iota
LargePlate
)
func (p PlateSize) Dims() image.Point {
switch p {
case SquarePlate:
return image.Pt(85, 85)
case LargePlate:
return image.Pt(85, 134)
}
panic("unreachable")
}
type Seed struct {
Title string
KeyIdx int
Mnemonic bip39.Mnemonic
Keys int
MasterFingerprint uint32
Font *vector.Face
Size PlateSize
}
type Descriptor struct {
Descriptor urtypes.OutputDescriptor
KeyIdx int
Font *vector.Face
Size PlateSize
}
func dims(c engrave.Plan) (engrave.Plan, image.Point) {
b := engrave.Measure(c)
return engrave.Offset(-b.Min.X, -b.Min.Y, c), b.Size()
}
var ErrDescriptorTooLarge = errors.New("output descriptor is too large to backup")
const MaxTitleLen = 18
const outerMargin = 3
const innerMargin = 10
func TitleString(face *vector.Face, s string) string {
s = strings.ToUpper(s)
res := ""
for _, r := range s {
if _, _, valid := face.Decode(r); valid {
res += string(r)
}
if len(res) == MaxTitleLen {
break
}
}
return res
}
type engraveFunc func(plateDims image.Point) (engrave.Plan, error)
func engraveSide(scale int, size PlateSize, eng engraveFunc) (engrave.Plan, error) {
sz := size.Dims().Mul(scale)
side, err := eng(sz)
if err != nil {
return nil, err
}
bounds := engrave.Measure(side)
safetyMargin := image.Pt(outerMargin*scale, outerMargin*scale)
if !bounds.In(image.Rectangle{Min: safetyMargin, Max: sz.Sub(safetyMargin)}) {
return nil, ErrDescriptorTooLarge
}
return side, nil
}
func EngraveSeed(params engrave.Params, plate Seed) (engrave.Plan, error) {
return engraveSide(params.Millimeter, plate.Size, func(plateDims image.Point) (engrave.Plan, error) {
return frontSideSeed(params, plate, plateDims)
})
}
func EngraveDescriptor(params engrave.Params, plate Descriptor) (engrave.Plan, error) {
return engraveSide(params.Millimeter, plate.Size, func(plateDims image.Point) (engrave.Plan, error) {
urs := splitUR(plate.Descriptor, plate.KeyIdx)
return descriptorSide(params, plate.Font, urs, plate.Size, plateDims)
})
}
// splitUR searches for the appropriate seqNum in the [UR] encoding
// that makes m-of-n backups recoverable regardless of
// which m-sized subset is used. To achieve that, we're exploiting the
// fact that the UR encoding of a fragment can contain multiple fragments,
// xor'ed together.
//
// Schemes are implemented for backups where m == n - 1 and for 3-of-5.
//
// For m == n - 1, the data is split into m parts (seqLen in UR parlor), and m shares have parts
// assigned as follows:
//
// 1, 2, ..., m
//
// The final share contains the xor of all m parts.
//
// The scheme can trivially recover the data when selecting the m shares each with 1
// part. For all other selections, one share will be missing, say k, but we'll have the
// final plate with every part xor'ed together. So, k is derived by xor'ing (canceling) every
// part other than k into the combined part.
//
// Example: a 2-of-3 setup will have data split into 2 parts, with the 3 shares assigned parts
// like so: 1, 2, 1 ⊕ 2. Selecting the first two plates, the data is trivially recovered;
// otherwise we have one part, say 1, and the combined part. The other part, 2, is then recovered
// by xor'ing the one part with the combination: 1 ⊕ 1 ⊕ 2 = 2.
//
// For 3-of-5, the data is split into 6 parts, and each share will have two parts assigned.
//
// The assignment is as follows, where p1 and p2 denotes the two parts assigned to each share.
//
// share | p1 | p2
// 1 1 6 ⊕ 5 ⊕ 2
// 2 2 6 ⊕ 1 ⊕ 3
// 3 3 6 ⊕ 2 ⊕ 4
// 4 4 6 ⊕ 3 ⊕ 5
// 5 5 6 ⊕ 4 ⊕ 1
//
// That is, every share is assigned a part and the combination of the 6 part with the neighbour
// parts.
//
// [UR]: https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md
func splitUR(desc urtypes.OutputDescriptor, keyIdx int) (urs []string) {
var shares [][]int
var seqLen int
m, n := desc.Threshold, len(desc.Keys)
switch {
case n-m <= 1:
// Optimal: 1 part per share, seqLen m.
seqLen = m
if keyIdx < m {
shares = [][]int{{keyIdx}}
} else {
all := make([]int, 0, m)
for i := 0; i < m; i++ {
all = append(all, i)
}
shares = [][]int{all}
}
case n == 4 && m == 2:
// Optimal, but 2 parts per share.
seqLen = m * 2
switch keyIdx {
case 0:
shares = [][]int{{0}, {1}}
case 1:
shares = [][]int{{2}, {3}}
case 2:
shares = [][]int{{0, 2}, {1, 3}}
case 3:
shares = [][]int{{0, 2, 1}, {1, 3, 2}}
}
case n == 5 && m == 3:
// Optimal, but 2 parts per share. There doesn't seem to exist an
// optimal scheme with 1 part per share.
seqLen = m * 2
second := []int{
n,
(keyIdx + n - 1) % n,
(keyIdx + 1) % n,
}
shares = [][]int{{keyIdx}, second}
default:
// Fallback: every share contains the complete data. It's only optimal
// for 1-of-n backups.
seqLen = 1
shares = [][]int{{0}}
}
data := desc.Encode()
check := fountain.Checksum(data)
for _, frag := range shares {
seqNum := fountain.SeqNumFor(seqLen, check, frag)
qr := strings.ToUpper(ur.Encode("crypto-output", data, seqNum, seqLen))
urs = append(urs, qr)
}
return
}
func Recoverable(desc urtypes.OutputDescriptor) bool {
var shares [][]string
for k := range desc.Keys {
shares = append(shares, splitUR(desc, k))
}
// Count to all bit patterns of n length, choose the ones with
// m bits.
allPerm := uint64(1)<<len(desc.Keys) - 1
for c := uint64(1); c <= allPerm; c++ {
if bits.OnesCount64(c) != desc.Threshold {
continue
}
c := c
d := new(ur.Decoder)
for c != 0 {
share := bits.TrailingZeros64(c)
c &^= 1 << share
for _, ur := range shares[share] {
d.Add(ur)
}
}
typ, enc, err := d.Result()
if err != nil {
return false
}
if enc == nil {
return false
}
got, err := urtypes.Parse(typ, enc)
if err != nil {
return false
}
gotDesc := got.(urtypes.OutputDescriptor)
gotDesc.Title = desc.Title
if !reflect.DeepEqual(gotDesc, desc) {
return false
}
}
return true
}
const plateFontSize = 4.1
const plateFontSizeUR = 3.8
const plateSmallFontSize = 3.
func frontSideSeed(params engrave.Params, plate Seed, plateDims image.Point) (engrave.Plan, error) {
constant := engrave.NewConstantStringer(plate.Font, params.F(plateFontSize), bip39.ShortestWord, bip39.LongestWord)
var cmds []engrave.Plan
cmd := func(c engrave.Plan) {
cmds = append(cmds, c)
}
maxCol1 := 16
maxCol2 := 4
endCol1 := maxCol1
if endCol1 > len(plate.Mnemonic) {
endCol1 = len(plate.Mnemonic)
}
col1, col1b := dims(wordColumn(constant, plate.Font, params.F(plateFontSize), plate.Mnemonic, 0, endCol1))
// Engrave version, mfp and page.
const version = "V1"
innerMargin := params.I(innerMargin)
metaMargin := params.I(4)
page := fmt.Sprintf("%d/%d", plate.KeyIdx+1, plate.Keys)
mfp := strings.ToUpper(fmt.Sprintf("%.8x", plate.MasterFingerprint))
{
offy := (plateDims.Y-col1b.Y)/2 - metaMargin
pagec, sz := dims(engrave.String(plate.Font, params.F(plateSmallFontSize), page).Engrave)
cmd(engrave.Offset(innerMargin, offy-sz.Y, pagec))
mfpc, sz := dims(engrave.String(plate.Font, params.F(plateSmallFontSize), mfp).Engrave)
cmd(engrave.Offset((plateDims.X-sz.X)/2, offy-sz.Y, mfpc))
txt, sz := dims(engrave.String(plate.Font, params.F(plateSmallFontSize), version).Engrave)
cmd(engrave.Offset(plateDims.X-sz.X-innerMargin, offy-sz.Y, txt))
}
// Engrave column 1.
cmd(engrave.Offset(innerMargin, (plateDims.Y-col1b.Y)/2, col1))
// Engrave (top of) column 2.
endCol2 := endCol1 + maxCol2
if endCol2 > len(plate.Mnemonic) {
endCol2 = len(plate.Mnemonic)
}
col2, _ := dims(wordColumn(constant, plate.Font, params.F(plateFontSize), plate.Mnemonic, endCol1, endCol2))
cmd(engrave.Offset(params.I(44), (plateDims.Y-col1b.Y)/2, col2))
// Engrave seed QR.
qrCmd, err := engrave.ConstantQR(params.StrokeWidth, 3, qr.Q, seedqr.CompactQR(plate.Mnemonic))
if err != nil {
return nil, err
}
qr, sz := dims(qrCmd)
cmd(engrave.Offset(params.I(60)-sz.X/2, (plateDims.Y-sz.Y)/2, qr))
{
// Engrave bottom of column 2.
col2, col2b := dims(wordColumn(constant, plate.Font, params.F(plateFontSize), plate.Mnemonic, endCol2, len(plate.Mnemonic)))
cmd(engrave.Offset(params.I(44), (plateDims.Y+col1b.Y)/2-col2b.Y, col2))
}
// Engrave title.
title := strings.ToUpper(plate.Title)
{
offy := (plateDims.Y+col1b.Y)/2 + metaMargin
title, sz := dims(engrave.String(plate.Font, params.F(plateSmallFontSize), title).Engrave)
cmd(engrave.Offset((plateDims.X-sz.X)/2, offy, title))
}
all := engrave.Commands(cmds...)
if plate.Size == LargePlate {
// Avoid the middle holes.
return engrave.Offset(0, params.F(24.5), all), nil
}
return all, nil
}
func wordColumn(constant *engrave.ConstantStringer, font *vector.Face, fontSize int, mnemonic bip39.Mnemonic, start, end int) engrave.Plan {
var cmds []engrave.Plan
y := 0
for i := start; i < end; i++ {
num := engrave.String(font, fontSize, fmt.Sprintf("%2d ", i+1))
d := num.Measure()
w := mnemonic[i]
word := strings.ToUpper(bip39.LabelFor(w))
txt := constant.String(word)
cmds = append(cmds,
engrave.Offset(0, y, num.Engrave),
engrave.Offset(d.X, y, txt),
)
y += d.Y
}
return engrave.Commands(cmds...)
}
func descriptorSide(params engrave.Params, fnt *vector.Face, urs []string, size PlateSize, plateDims image.Point) (engrave.Plan, error) {
var cmds []engrave.Plan
cmd := func(c engrave.Plan) {
cmds = append(cmds, c)
}
fontSize := params.F(plateFontSizeUR)
str := func(s string) engrave.Plan {
return engrave.String(fnt, fontSize, s).Engrave
}
// Compute character width, assuming the font is fixed width.
charWidthf, _, ok := fnt.Decode('W')
if !ok {
panic("W not in font")
}
charWidth := int(float32(charWidthf*fontSize) / float32(fnt.Metrics().Height))
margin := params.I(outerMargin)
innerMargin := params.I(innerMargin)
if size == LargePlate {
margin = innerMargin
}
holeChars := int(math.Ceil(float64(innerMargin-margin) / float64(charWidth)))
holeLines := int(math.Ceil(float64(innerMargin-margin) / float64(fontSize)))
width := plateDims.X - 2*margin
charPerLine := int(width / charWidth)
offy := params.I(outerMargin)
for i, ur := range urs {
qrcmd, err := engrave.QR(params.StrokeWidth, 2, qr.M, []byte(ur))
if err != nil {
return nil, err
}
qr, qrsz := dims(qrcmd)
qrBorder := params.I(2)
charPerQRLine := (width - 2*qrBorder - qrsz.X) / charWidth
qrLines := (qrsz.Y + 2*qrBorder + fontSize - 1) / fontSize
qrLineStart := holeLines
lineno := 0
for len(ur) > 0 {
n := charPerLine
offx := 0
isQRLine := qrLineStart <= lineno && lineno < qrLineStart+qrLines
if isQRLine {
n = charPerQRLine
}
// Avoid screw holes on the smaller plates on the first and last lines.
holeLine := offy+lineno*fontSize < innerMargin ||
offy+(lineno+1)*fontSize > plateDims.Y-innerMargin
if holeLine {
if !isQRLine {
// End of line.
n -= holeChars
}
// Beginning of line.
n -= holeChars
offx = holeChars * charWidth
}
if n < 1 {
n = 1
}
if n > len(ur) {
n = len(ur)
}
s := ur[:n]
ur = ur[n:]
cmd(engrave.Offset(offx+margin, offy+lineno*fontSize, str(s)))
lineno++
}
qrx := plateDims.X - qrsz.X - margin - qrBorder
qry := qrLineStart*fontSize + (qrLines*fontSize-qrsz.Y)/2
cmd(engrave.Offset(qrx, offy+qry, qr))
offy += lineno * fontSize
if i != len(urs)-1 {
// Space UR sections.
offy += params.I(1)
}
}
return engrave.Commands(cmds...), nil
}