Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ fuzz/timeout-*
fuzz/oom-*
fuzz/slow-unit-*
fuzz/corpus/*/[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]
fuzz/build/

output/
51 changes: 51 additions & 0 deletions Dockerfile.reproducible
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# SPDX-FileCopyrightText: © 2026 PrivKey LLC
# SPDX-License-Identifier: AGPL-3.0-or-later

FROM espressif/idf:v5.4.1@sha256:6b4adab7b282e9261a154d7130fb4945a3c28bd35c2e914357c2f522643cb92a AS builder

ARG SOURCE_DATE_EPOCH=0
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
ENV IDF_CCACHE_ENABLE=0
ENV IDF_PATH=/opt/esp/idf
ENV IDF_PATH_FORCE=1

WORKDIR /build

COPY . keep-esp32/

RUN for repo in \
"privkeyio/secp256k1-frost 832bac2dbfaf4c35058cd5dadb0f8fe093cb6c65" \
"privkeyio/libnostr-c 4e8d01b8d20680204429aaf3680d069ec222fdaa" \
"privkeyio/noscrypt 45fcbef32f958145d6797f384a225c0d43616886" \
"ElementsProject/libwally-core 242f841af6819b5d5174da5a9593907f56f1bc89"; do \
set -- $repo; \
git clone "https://github.com/$1.git" && \
git -C "$(basename $1)" checkout "$2"; \
done

RUN . $IDF_PATH/export.sh && \
cd keep-esp32 && \
idf.py set-target esp32s3 && \
idf.py build && \
mkdir -p /out && \
cp build/keep.bin /out/ && \
cp build/bootloader/bootloader.bin /out/ && \
cp build/partition_table/partition-table.bin /out/ && \
cp build/ota_data_initial.bin /out/

RUN . $IDF_PATH/export.sh && \
cd /out && \
esptool.py --chip esp32s3 merge_bin \
--flash_mode dio \
--flash_freq 80m \
--flash_size 8MB \
-o keep-merged.bin \
0x0 bootloader.bin \
0x8000 partition-table.bin \
0xd000 ota_data_initial.bin \
0x10000 keep.bin && \
sha256sum keep.bin > keep.bin.sha256 && \
sha256sum keep-merged.bin > keep-merged.bin.sha256

FROM scratch AS export
COPY --from=builder /out/* /
128 changes: 128 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# SPDX-FileCopyrightText: © 2026 PrivKey LLC
# SPDX-License-Identifier: AGPL-3.0-or-later

set shell := ["bash", "-uc"]

docker_cmd := env("DOCKER_CMD", "docker")
_valid_docker := if docker_cmd == "docker" { "ok" } else { if docker_cmd == "podman" { "ok" } else { error("DOCKER_CMD must be 'docker' or 'podman'") } }
port_raw := env("PORT", "/dev/ttyACM0")
port := if port_raw =~ '^/dev/tty[A-Za-z0-9]+$' { port_raw } else { error("PORT must match /dev/tty* pattern") }

default:
@just --list

build:
idf.py build

flash:
idf.py -p {{port}} flash

monitor:
idf.py -p {{port}} monitor

flash-monitor:
idf.py -p {{port}} flash monitor

test:
#!/usr/bin/env bash
set -euo pipefail
cd test/native
mkdir -p build && cd build
cmake ..
make -j$(nproc)
for t in test_frost test_session test_storage test_secure_element test_secresult \
test_integration test_self_test test_hw_entropy test_anti_glitch test_psbt_fraud \
test_frost_signer_core test_protocol test_integration_full test_psbt_fraud_integration; do
[ ! -f "./$t" ] || ./$t
done

fuzz target="" duration="30":
#!/usr/bin/env bash
set -euo pipefail
if [ -n "{{target}}" ] && ! [[ "{{target}}" =~ ^[a-zA-Z0-9_]+$ ]]; then
echo "Error: target must be alphanumeric (with underscores)" >&2
exit 1
fi
if ! [[ "{{duration}}" =~ ^[0-9]+$ ]]; then
echo "Error: duration must be a positive integer" >&2
exit 1
fi
cd fuzz
mkdir -p build && cd build
CC=clang cmake ..
make -j$(nproc)
if [ -n "{{target}}" ]; then
./fuzz_{{target}} ../corpus/{{target}} -max_total_time={{duration}}
else
for fuzzer in fuzz_hex fuzz_protocol fuzz_dkg fuzz_policy fuzz_nostr; do
if [ -f "./$fuzzer" ]; then
corpus="${fuzzer#fuzz_}"
echo "Running $fuzzer..."
timeout {{duration}} ./$fuzzer ../corpus/$corpus -max_total_time={{duration}} || [ $? -eq 124 ]
fi
done
fi

docs:
doxygen Doxyfile

clean:
rm -rf build test/native/build fuzz/build docs/html output

docker-build:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p output
{{docker_cmd}} build -f Dockerfile.reproducible -o output .
sha256sum output/keep.bin
sha256sum output/keep-merged.bin

verify-sha expected="":
#!/usr/bin/env bash
set -euo pipefail
[ -f output/keep.bin ] || { echo "Run 'just docker-build' first"; exit 1; }
BUILT_HASH=$(sha256sum output/keep.bin | cut -d' ' -f1)
echo "Build hash: $BUILT_HASH"
if [ -n "{{expected}}" ]; then
if ! [[ "{{expected}}" =~ ^[a-f0-9]{64}$ ]]; then
echo "Error: expected must be a valid SHA256 hash (64 hex chars)" >&2
exit 1
fi
[ "$BUILT_HASH" = "{{expected}}" ] || { echo "Mismatch: expected {{expected}}"; exit 1; }
echo "Match"
fi

verify-release version:
#!/usr/bin/env bash
set -euo pipefail
if ! [[ "{{version}}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be in semver format (vX.Y.Z)" >&2
exit 1
fi
[ -f output/keep.bin ] || { echo "Run 'just docker-build' first"; exit 1; }
BUILT_HASH=$(sha256sum output/keep.bin | cut -d' ' -f1)
TMP=$(mktemp -d)
trap "rm -rf $TMP" EXIT
curl -fSL "https://github.com/privkeyio/keep-esp32/releases/download/{{version}}/keep-esp32-firmware-{{version}}.tar.gz" | tar -xz -C "$TMP"
RELEASE_HASH=$(sha256sum "$TMP/keep.bin" | cut -d' ' -f1)
echo "Built: $BUILT_HASH"
echo "Release: $RELEASE_HASH"
[ "$BUILT_HASH" = "$RELEASE_HASH" ] || { echo "Mismatch"; exit 1; }
echo "Match"

verify-device:
#!/usr/bin/env bash
set -euo pipefail
[ -f output/keep.bin ] || { echo "Run 'just docker-build' first"; exit 1; }
SIZE=$(wc -c < output/keep.bin | tr -d '[:space:]')
TMP=$(mktemp)
trap "rm -f $TMP" EXIT
esptool.py --port {{port}} read_flash 0x10000 "$SIZE" "$TMP"
DEVICE_HASH=$(sha256sum "$TMP" | cut -d' ' -f1)
BUILT_HASH=$(sha256sum output/keep.bin | cut -d' ' -f1)
echo "Device: $DEVICE_HASH"
echo "Built: $BUILT_HASH"
[ "$DEVICE_HASH" = "$BUILT_HASH" ] || { echo "Mismatch"; exit 1; }
echo "Match"

ci: test docs
92 changes: 92 additions & 0 deletions REPRODUCIBILITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Reproducible Builds

Verify that firmware binaries match the source code.

## Quick Start

```bash
# Build in Docker
just docker-build

# Compare with release
just verify-release v1.0.0
```

## Requirements

- Docker or Podman
- [just](https://github.com/casey/just)
- esptool (for device verification)

## Build

```bash
# Using Docker (default)
just docker-build

# Using Podman
DOCKER_CMD=podman just docker-build
```

Output files in `output/`:
- `keep.bin` - Application firmware
- `keep-merged.bin` - Full flash image
- `bootloader.bin` - Bootloader
- `partition-table.bin` - Partition table
- `ota_data_initial.bin` - OTA data

## Verify Release

Compare your build against a published release:

```bash
just docker-build
just verify-release v1.0.0
```

## Verify Device

Compare firmware on a connected device:

```bash
just docker-build
just verify-device

# Custom port
PORT=/dev/ttyUSB0 just verify-device
```

## Manual Verification

```bash
# Build
just docker-build

# Check hash
sha256sum output/keep.bin

# Compare with expected
just verify-sha abc123...
```

## Justfile Commands

| Command | Description |
|---------|-------------|
| `just build` | Build with local ESP-IDF |
| `just flash` | Flash to device |
| `just test` | Run native tests |
| `just fuzz` | Run fuzz tests |
| `just docs` | Generate documentation |
| `just clean` | Remove build artifacts |
| `just docker-build` | Reproducible build in Docker |
| `just verify-sha` | Compare build hash |
| `just verify-release VERSION` | Compare with release |
| `just verify-device` | Compare with device firmware |

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `DOCKER_CMD` | `docker` | Container runtime (`docker` or `podman`) |
| `PORT` | `/dev/ttyACM0` | Serial port for device operations |
14 changes: 13 additions & 1 deletion REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,16 @@ SPDX-License-Identifier = "AGPL-3.0-or-later"
path = "**.html"
precedence = "aggregate"
SPDX-FileCopyrightText = "© 2026 PrivKey LLC"
SPDX-License-Identifier = "AGPL-3.0-or-later"
SPDX-License-Identifier = "AGPL-3.0-or-later"

[[annotations]]
path = "Justfile"
precedence = "aggregate"
SPDX-FileCopyrightText = "© 2026 PrivKey LLC"
SPDX-License-Identifier = "AGPL-3.0-or-later"

[[annotations]]
path = "Dockerfile*"
precedence = "aggregate"
SPDX-FileCopyrightText = "© 2026 PrivKey LLC"
SPDX-License-Identifier = "AGPL-3.0-or-later"