Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5187564
fix: scan recovery-share strips back in, and one unlock path at a time
404SecNotFound Sep 23, 2026
7fac837
fix: keep one-time shares and their container from being lost
404SecNotFound Sep 23, 2026
c8c61ad
fix(scripts): close the shares dialog with its button in the audits
404SecNotFound Sep 23, 2026
add117c
fix: carrier, service worker and recovery-script defects, and doc drift
404SecNotFound Sep 23, 2026
43582c1
fix: returning from Tools no longer wipes the form; changelog
404SecNotFound Sep 23, 2026
a0c37b0
fix: print paper-vault symbols as SVG so multi-part backups scan back
404SecNotFound Sep 23, 2026
bf3608a
build: resolve script paths with fileURLToPath so a spaced checkout b…
404SecNotFound Sep 23, 2026
0168775
ci: refuse to sign a manifest the independent builds did not reproduce
404SecNotFound Sep 23, 2026
8b81cd7
ci: run the reference self-tests on Python 3.10, the RECOVERY.md floor
404SecNotFound Sep 23, 2026
986cf15
ci: correct stale workflow comments
404SecNotFound Sep 23, 2026
2642568
fix: erase the secret copies the v2 unlock path made and dropped
404SecNotFound Sep 23, 2026
8349285
fix: size the container ceiling from the format's worst case
404SecNotFound Sep 23, 2026
d319e66
fix: UI correctness pass (lock, scans, notices, filenames, a11y)
404SecNotFound Sep 23, 2026
af0f8d9
fix(format): one rule for ignorable characters, full set ids, confirm…
404SecNotFound Sep 23, 2026
2043402
Merge build-script and CI hardening
404SecNotFound Sep 23, 2026
42eed12
Merge secret-erasure hygiene in the crypto core
404SecNotFound Sep 23, 2026
25b93ff
docs: changelog for the UI, parity, hygiene and CI work
404SecNotFound Sep 23, 2026
b4755e1
Merge remote-tracking branch 'origin/main' into claude/trusting-fermi…
404SecNotFound Sep 23, 2026
f0ab8a0
test(recovery): execute RECOVERY.md's commands, fix its v3 drift
404SecNotFound Sep 23, 2026
7b180e6
feat(shares): 2-of-3 and 3-of-5 presets beside the custom fields
404SecNotFound Sep 23, 2026
6d24b44
feat(shares): a set code on the owner's sheet and on every strip
404SecNotFound Sep 23, 2026
3a56a18
feat(recovery): check a printout against its backup, opening nothing
404SecNotFound Sep 23, 2026
e72fa34
feat(paper): print version-25 symbols; keym2.py split writes KMPART2
404SecNotFound Sep 23, 2026
22c7cdf
feat(scan): read every QR code in one photo
404SecNotFound Sep 23, 2026
161e919
feat(scan): live camera scanning for strips and container symbols
404SecNotFound Sep 23, 2026
4b1b079
feat(paper): offer strips that carry a small backup, with the cost st…
404SecNotFound Sep 23, 2026
1e4516a
feat(format): §4.8 password-and-shares slot, spec to UI
404SecNotFound Sep 23, 2026
c6f0341
fix(ci): pin the Python 3.10 closure instead of installing with --no-…
404SecNotFound Sep 23, 2026
61f371c
fix(self-extract): forget earlier results, and give the right refusal…
404SecNotFound Sep 24, 2026
0132dc9
fix(audio): read WAV carriers of any common depth without resampling
404SecNotFound Sep 24, 2026
d353bd6
fix(sealed): claim only what the page's policy enforces
404SecNotFound Sep 24, 2026
48a2623
docs(keym2.py): correct stale text, and test the slot-walk guard it m…
404SecNotFound Sep 24, 2026
c34d88c
fix(core): type every lazy module failure, and erase decoded share re…
404SecNotFound Sep 24, 2026
b5c1393
test(passkey): Stop during a slow file read never asks the authenticator
404SecNotFound Sep 24, 2026
d11f9d6
docs(changelog): the self-extract, audio, sealed-panel, module-load a…
404SecNotFound Sep 24, 2026
50960fa
test(printout-check): wait for the whole-page photo's own results
404SecNotFound Sep 24, 2026
f6b26d5
test(lock): hold the unlock open in every engine, and say why if it i…
404SecNotFound Sep 24, 2026
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
107 changes: 100 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: CI

# Build + typecheck + the KEYM v1 regression suite.
# Typecheck, the Node-side suites (KEYM regression, fuzzing, the workflow and
# document gates), the production build with its palette and icon audits, and
# the checks that the build is reproducible.
#
# This is deliberately separate from crypto-regression.yml. That workflow
# gates the frozen IBTZ core and runs with no `npm ci` at all, so no
Expand All @@ -16,6 +18,16 @@ name: CI
on:
pull_request:
workflow_call:
inputs:
# release.yml passes the tag it is about to build, so the
# reproducible-elsewhere legs below build the same channel it publishes
# and its sign job compares like with like. Empty (every PR, and
# deploy.yml) builds the development channel, which is what deploy.yml
# publishes.
release-tag:
type: string
required: false
default: ""
workflow_dispatch:

permissions: {}
Expand Down Expand Up @@ -84,6 +96,16 @@ jobs:
- name: Owned secret buffers are zeroed on every path
run: npm run test:secret-erase

# The copies the crypto core makes for itself: the length-prefixed KDF
# input, the joined key file behind its digest, a second plaintext left
# by decryptData, share values decoded before a malformed share rejected
# the set, and the worker's key-file copy when an enrolment throws. None
# is visible to a caller, so this takes a census of every Uint8Array the
# core allocates during a call and searches what survives. Also pins a
# Shamir chunk that fails to load as a typed dependency error.
- name: Internal secret copies are zeroed by the crypto core
run: npm run test:secret-erase-core

# The plaintext input buffer is transferred (and so detached/erased) on the
# worker path, but the no-worker fallback hands it to encryptContainer
# in-thread and never transfers it — and neither encryptContainer nor
Expand Down Expand Up @@ -112,15 +134,48 @@ jobs:
run: npm run test:audio-wav-bounds

# Every other malformed carrier is a typed AudioStegoError too, on both the
# WAV parse (not RIFF, no data chunk, non-PCM, non-16-bit, zero channels)
# WAV parse (not RIFF, no data chunk, an unread encoding, zero channels)
# and the KAUD extract path (no marker, unreadable version/depth, empty or
# over-long declared payload). A carrier fault must never reach the AEAD as
# "decryption failed". Controls bite: removing the payload-length bound
# returns garbage instead of throwing; removing the format/bit-depth check
# misreads a 24-bit file instead of refusing it.
# returns garbage instead of throwing; removing the encoding check misreads
# an ADPCM file instead of refusing it.
- name: Malformed audio carriers are all typed carrier errors
run: npm run test:audio-malformed

# A WAV carrier at 8, 24 or 32-bit PCM or 32/64-bit float, plain or
# WAVE_FORMAT_EXTENSIBLE, is read at its own rate and scaled to 16 bits by
# a power of two, so a stego WAV re-saved losslessly at a greater depth
# still reveals. It used to be refused as "Only 16-bit PCM WAV" while the
# format doc promised any WAV. Control bites: restoring the refusal fails
# every conversion case; dropping the SubFormat GUID check reads a foreign
# EXTENSIBLE header as PCM.
- name: WAV carriers of any common depth are read without resampling
run: npm run test:audio-wav-depths

# A carrier decoded through Web Audio must come back sample-exact. Web
# Audio returns a 16-bit sample s as s / 32768; scaling positives back by
# 32767 flipped the low bit of every sample above 16384, which is where
# the payload lives. A WAV is also recognised by its RIFF/WAVE bytes, not
# only its name, so a renamed stego file keeps the exact parser. Controls
# bite: the 32767 scale fails 16,383 values; a sniff that never matches
# fails the byte check.
- name: Audio decode is sample-exact, and WAVs are sniffed by content
run: npm run test:audio-decode-scale

# The service worker's shell URLs are not content-hashed, so install must
# fetch them past the HTTP cache (cache: 'reload'). Otherwise a deploy
# inside Pages' max-age window froze the previous index.html into the new
# version's cache: a false tamper report beside the new SHA256SUMS, and a
# blank page offline. Runs public/sw.js itself in a stub worker scope,
# and also checks offline lookups use the worker's own cache (Pages puts
# every project site on one origin) and that /verify.html opens offline.
# Controls bite: plain-string addAll fails the reload check; dropping
# requirements.txt from the shell fails the recovery-kit check; going
# back to caches.match fails the three offline lookups.
- name: Service worker precaches a fresh shell and the whole recovery kit
run: npm run test:sw-precache

# The "sealed" verdict must rest on the whole egress-relevant CSP set, not
# connect-src alone: connect-src 'none' stops fetch/XHR/WebSocket but a
# form POST is governed by form-action, which does not fall back to
Expand All @@ -131,6 +186,20 @@ jobs:
- name: Sealed verdict requires the full egress directive set
run: npm run test:seal-verdict

# "Check this printout": a printed strip or symbol reads back, and is from
# this backup or is said not to be, without joining or decrypting
# anything. Controls bite: a four-byte KMSHARE2 set-id compare, a skipped
# part checksum, and a header treated as a whole container each fail it.
- name: Printout check matches codes to their backup
run: npm run test:printout-check

# The live camera scanner's stopping rule: when enough strips and symbols
# are in. Controls bite: a repeated strip counted twice, a strip from
# another set counted, and strips alone taken as complete when container
# symbols have been started each fail it.
- name: Camera scanner stops when enough is read, and not before
run: npm run test:camera-progress

# The build's counterpart of the sealed verdict: apply-csp-hashes.mjs fails
# the build unless default-src, connect-src AND form-action are all 'none'
# in every shipped page's CSP. connect-src has been gated since KM-07;
Expand Down Expand Up @@ -174,6 +243,15 @@ jobs:
- name: Publication is gated on the test suites
run: npm run test:release-gate

# The step in deploy.yml and release.yml that refuses to sign a manifest
# the reproducible-elsewhere legs below did not reproduce byte for byte.
# It only ever runs inside a publish, which is too late to learn it
# accepts a mismatch, so its refusals are exercised here on every PR. The
# control bites: a comparison that always agrees passes a differing,
# extra or missing file, and a minimum of zero signs on no evidence.
- name: Signing refuses a manifest no independent build reproduced
run: npm run test:reproduced-manifest

- name: KEYM v1 regression suite
run: npm run test:keymaker

Expand Down Expand Up @@ -334,7 +412,12 @@ jobs:
#
# - **the checkout path**, because an absolute path that leaks into a bundle
# or a source map is the classic way a build stops being portable, and it
# is invisible to anyone who only ever builds in one directory;
# is invisible to anyone who only ever builds in one directory. The
# second leg's path also has a space and non-ASCII letters in it, because
# a verifier's home directory might, and the build broke there once:
# `new URL('../out', import.meta.url).pathname` is percent-encoded, so
# the post-build scripts looked for `…/a%20checkout…/out`, which does not
# exist. That leg failing is the regression test for it;
# - **the Node major**, because README.md tells people 22.22.2 *or newer* is
# supported, and a verifier on a newer major who gets different bytes has
# been told to expect a match.
Expand All @@ -353,9 +436,9 @@ jobs:
- id: node22
node: 22
dir: keymaker
- id: node22-longer-checkout-path
- id: node22-spaced-non-ascii-checkout-path
node: 22
dir: a-checkout-directory-named-nothing-like-the-other-one
dir: "a checkout directory named nothing like the other one ünï"
- id: node24
node: 24
dir: keymaker
Expand All @@ -382,13 +465,23 @@ jobs:
# reason verify-reproducible.mjs pins it: a PR checkout is a merge commit
# that does not exist upstream, and letting each leg resolve it from git
# would be comparing git rather than the build.
#
# The channel is the caller's: empty on a PR and under deploy.yml, the
# tag under release.yml. It arrives through `env`, never interpolated into
# a script, because a tag name is text its author controls.
- name: Build
run: npm run build
working-directory: ${{ matrix.dir }}
env:
KEYMAKER_BASE_PATH: /Keymaker-v2
KEYMAKER_BUILD_ID: ${{ github.sha }}
KEYMAKER_RELEASE_TAG: ${{ inputs.release-tag }}

# Consumed twice: by the comparison job below, and, when deploy.yml or
# release.yml called this workflow, by their sign job, which refuses to
# sign a manifest these legs did not reproduce
# (scripts/check-reproduced-manifest.mjs). Keep the `sums-` prefix; both
# download by it.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sums-${{ matrix.id }}
Expand Down
75 changes: 63 additions & 12 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
name: Format conformance

# Cross-tests the TypeScript implementation against an independent Python
# reference written only from docs/FORMAT.md.
# Cross-tests the TypeScript implementation against independent Python
# references written only from the format documents: reference/keym.py from
# docs/FORMAT.md (v1), and reference/keym2.py from docs/FORMAT-V2-DESIGN.md and
# the v3 delta in docs/FORMAT-V3-DESIGN.md.
#
# The frozen fixtures prove Keymaker stays compatible with itself. They cannot
# prove it matches its own specification, because the implementation that
Expand Down Expand Up @@ -57,15 +59,15 @@ jobs:
- name: Reference self-test (KEYM v1)
run: python3 reference/keym.py selftest

# KEYM v2 has no TypeScript yet, so there is nothing to cross-test
# against — which is the point. The reference is written from
# docs/FORMAT-V2-DESIGN.md *first*, so the specification is what gets
# debugged, before an implementation exists to enshrine its gaps. It has
# already found four (see §11 of that document, and §A of keym2.py).
#
# Running it in CI from now keeps those findings from rotting as the
# proposal is edited.
- name: Reference self-test (KEYM v2, spec-first)
# The reference on its own, before it is compared with anything. Each
# format change is written into docs/FORMAT-V2-DESIGN.md first and
# implemented in keym2.py from that section alone, before the TypeScript,
# so the specification is what gets debugged rather than an
# implementation's reading of it. The findings that produced are in §11
# of that document and §A of keym2.py; this keeps them from rotting as
# the spec is edited. The byte-for-byte cross-test against the
# TypeScript follows.
- name: Reference self-test (KEYM v2 and v3, spec-first)
run: npm run test:keym2

# Byte equality, not just round-trips. For v1 a bidirectional round-trip
Expand All @@ -75,7 +77,6 @@ jobs:
# decode correctly — so two writers could disagree, round-trip perfectly
# in both directions, and still produce incompatible files.
- name: Bidirectional conformance (KEYM v2 and v3, byte-for-byte)

run: npm run test:conformance2

- name: Bidirectional conformance
Expand All @@ -87,6 +88,56 @@ jobs:
- name: Recovery procedure works as documented
run: npm run test:recovery

# ---------------------------------------------------------------- oldest supported Python
#
# docs/RECOVERY.md tells whoever is recovering a backup that the scripts need
# "Python 3.10 or newer", and every other job here runs 3.12, so nothing
# tested the floor of that promise. A 3.11-only construct in keym.py or
# keym2.py (`except*`, `tomllib`, `typing.Self`) would pass the job above and
# fail on exactly the old machine the page is written for. The pinned wheels
# sit on the same floor: cffi 2.1.1 and argon2-cffi-bindings 26.1.0 both
# declare Requires-Python >=3.10, so a routine bump could drop 3.10 with
# nothing else noticing.
#
# The self-tests only: they are the scripts' own known-answer vectors, which
# is the part an heir's interpreter has to run. The cross-tests drive the
# TypeScript and add nothing about the Python version.
reference-python-floor:
name: Reference self-tests on Python 3.10 (the RECOVERY.md floor)
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.10"

# A job that quietly ran the runner's default interpreter would pass while
# testing 3.12 a second time.
- name: The interpreter is the documented floor
run: |
python3 --version
python3 -c 'import sys; sys.exit(0 if sys.version_info[:2] == (3, 10) else "expected Python 3.10, got " + sys.version)'

# Hash-checked, with dependencies resolved as on any other install. On
# 3.10 cryptography also needs typing-extensions (it declares it for
# python_full_version < '3.11'); scripts/pin-conformance-deps.py now
# resolves the closure for 3.10 as well as 3.12, so it is pinned. This
# job used --no-deps until it was, which installed the pinned bytes but
# could not tell a missing dependency from a present one.
- name: Install the pinned reference dependencies
run: pip install --require-hashes -r reference/conformance-requirements.txt

- name: Reference self-test (KEYM v1) on Python 3.10
run: python3 reference/keym.py selftest

- name: Reference self-test (KEYM v2 and v3) on Python 3.10
run: python3 reference/keym2.py selftest

# ---------------------------------------------------------------- dependency audit
#
# Its own job, not a step in `conformance`. The two answer different
Expand Down
17 changes: 10 additions & 7 deletions .github/workflows/crypto-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ name: Crypto regression

# src/lib/crypto.ts is frozen — people hold long-horizon secrets encrypted
# with it. This runs the fixture-based regression suite (real ciphertexts
# from earlier releases) on every push and PR, so a dependency bump or
# toolchain change can never silently break decryption of existing files.
# from earlier releases) on every PR and before every deploy and release, so a
# dependency bump or toolchain change can never silently break decryption of
# existing files.

# No `push` on main: deploy.yml calls this via `workflow_call` before it
# publishes (R01), so main is verified once as a deploy prerequisite rather
Expand All @@ -26,11 +27,13 @@ jobs:
with:
persist-credentials: false

# Node 22 across every workflow — build, test and deploy. The suite is
# TypeScript executed directly via native type stripping (on by default
# since 22.18), which avoids adding a transpiler dependency. Keeping one
# runtime everywhere removes a class of "green in CI, different on
# deploy" divergence from a pipeline that ships cryptographic software.
# Node 22 for every build, test and deploy job; the one exception is the
# reproducible-elsewhere leg in ci.yml that exists to try Node 24. The
# suite is TypeScript executed directly via native type stripping (on by
# default since 22.18), which avoids adding a transpiler dependency.
# Keeping one runtime everywhere removes a class of "green in CI,
# different on deploy" divergence from a pipeline that ships
# cryptographic software.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
Expand Down
29 changes: 25 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,12 @@ jobs:

build:
# Gate the whole publish chain on verification. build -> sign -> deploy, so
# gating build gates all three. The bytes built here are byte-identical to
# the ones verify-ci built and proved reproducible, so "publish the tested
# artifact" holds without threading the artifact between workflows.
# gating build gates all three. The bytes built here should be
# byte-identical to the ones verify-ci built and proved reproducible, so
# "publish the tested artifact" holds without threading the artifact
# between workflows. `sign` checks that rather than assuming it: it refuses
# to sign unless this job's SHA256SUMS matches every reproducible-elsewhere
# leg's.
needs: [verify-crypto, verify-ci, verify-conformance, verify-browser]
runs-on: ubuntu-latest
permissions:
Expand Down Expand Up @@ -97,8 +100,13 @@ jobs:
# therefore never held while untrusted transitive install scripts execute.
# The same reasoning gives this job `--ignore-scripts`: it holds the token,
# so nothing it installs is allowed to run code.
#
# `verify-ci` is named here as well as through `build` because this job reads
# its artifacts: the SHA256SUMS each reproducible-elsewhere leg uploaded. A
# called workflow runs inside this run, so they download like `site` does,
# with no permission beyond the ones below.
sign:
needs: build
needs: [build, verify-ci]
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -122,6 +130,19 @@ jobs:
name: site
path: out

# Outside out/, so none of it is published.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: sums-*
path: reproduced

# The manifest about to be signed must be the one the verified builds
# produced. verify-ci's legs each built this commit on their own runner;
# this job's `site` came from `build`, which no suite ever looked at. If
# they differ, the signature would certify bytes nothing tested.
- name: The manifest being signed is the one independent builds reproduced
run: node scripts/check-reproduced-manifest.mjs out/SHA256SUMS reproduced

- name: Sign the build manifest
run: node scripts/sign-manifest.mjs

Expand Down
Loading
Loading