Skip to content

Integration: all review PRs in merge order (one merge instead of twelve) - #57

Closed
deanrie wants to merge 31 commits into
seQRets:mainfrom
deanrie:integration/review-2026-10
Closed

deanrie wants to merge 31 commits into
seQRets:mainfrom
deanrie:integration/review-2026-10

Conversation

@deanrie

@deanrie deanrie commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

The twelve review PRs all touch the built site/index.html, so merging them one by one means a conflict and an npm run build after almost every one. This branch is the alternative: all twelve merged in dependency order, conflicts resolved in the sources only, the artifacts rebuilt once at the end, and everything green. Merge this, or the individual PRs — not both. Each individual PR remains open for review and links here.

Contains, in merge order

  1. CI: verify site/index.html is reproducible from scripts/build #45 CI reproducibility · SECURITY.md, security.txt, release workflow with provenance, fixtures per release #46 SECURITY.md, security.txt, release workflow with provenance, fixtures per release · test:ui — 60 checks on both shipped pages in headless Chrome, judged by crypto.ts #49 test:ui → Contrast: the faint hint colour was under WCAG AA; raised, and test:ui now measures every text element #56 contrast fix + audit
  2. Normalize passwords to NFC; decrypt tries NFC then the exact typed form #43 NFC password normalization → Assemble the recovery tool from the same crypto core as the app #47 recovery tool assembled from the shared crypto core
  3. Accept Diceware-style passphrases in the password strength gate #44 Diceware-style passphrases accepted → Type the password twice before encrypting; an 8-word passphrase generator from the built-in BIP-39 list #52 type the password twice; 8-word BIP-39 passphrase generator
  4. Keyboard-navigable tabs, key-file bytes erased after use, honest wording on the AES card #48 keyboard tabs, key-file erase, wording · Refuse an empty key file; hand binary results of text-mode decryption over as a file #50 empty key file refused, binary text-mode results downloaded · Several files at once; a fingerprint for the key file #54 several files at once, key-file fingerprint · Secret field keeps the keyboard out; status announced; nothing secret prints; reduced motion #51 secret-field attributes, live status, print rule, reduced motion → Print card: a one-page emergency sheet for encrypted text #55 printable emergency card

What the integration surfaced (fixed here, in three small commits)

Verified

  • npm run build twice → byte-identical; committed site/ and SHA256SUMS.txt equal the build output (what CI: verify site/index.html is reproducible from scripts/build #45's gate checks); update-csp-hashes --check current; fixtures:check 5 present for v3.0.11.
  • test:crypto 105/105 · test:ui 64/64.
  • All three workflows run end-to-end in a Linux container via act: crypto-regression.yml (with the reproducibility gate) green, ui.yml green under Chromium, release.yml as a v3.0.11 tag push green through staging (the hashes differ from the published v3.0.11, as they must — this is a new build).

Version string and CHANGELOG are yours; this touches neither.

deanrie added 30 commits October 6, 2026 22:28
The shipped artifact is committed but is meant to be a pure function of
scripts/build/* and src/lib/bip39.ts. The regression job tested the
committed HTML but never checked it matched a fresh build, so a hand edit
to site/index.html (or a source edit without 'npm run build') could ship
unnoticed. Rebuild in CI and fail on any diff in site/ or SHA256SUMS.txt.
The same visible password can arrive as different code points: "é" is
U+00E9 (NFC) on most keyboards but "e"+U+0301 (NFD) from some macOS
inputs, IMEs and pasted text. TextEncoder turns those into different
UTF-8 bytes, so PBKDF2 derived a different key and a file encrypted on
one machine failed on another with a misleading "wrong password".

Encryption now NFC-normalizes the password in all three implementations
(src/lib/crypto.ts, the app's crypto core, the recovery tool). Decryption
tries NFC first and, only when the typed string was not already NFC,
retries with the exact typed bytes — which is how every pre-existing
ciphertext was keyed. Nothing that decrypted before stops decrypting;
the only cost is one extra PBKDF2 run for a wrong non-NFC password.

Regression suite:
- new fixture "nfd-password": produced by the unmodified v3.0.11
  crypto.ts with an NFD password, kept as a JSON \u0301 escape so no
  editor can recompose it; proves the exact-bytes fallback.
- fixtures may now carry their own "password".
- NFC<->NFD cross checks for crypto.ts, the app core and the recovery
  file (6 checks that fail against the previous code, pass now).

site/index.html, site/ittybitz-recovery.html and SHA256SUMS.txt are the
rebuilt artifacts with re-pinned CSP hashes (npm run build).
The gate required 24+ characters AND upper, lower, digit and symbol. That
rejects a 7-word Diceware passphrase (~90 bits) while passing
"Aaaaaaaaaaaaaaaaaaaaaa1!". NIST SP 800-63B recommends length over
composition rules.

The existing rule is unchanged (and still what Generate produces). A
second way to pass is added: 24+ characters made of 6+ different words
of 3+ letters, separated by spaces, hyphens or underscores. Repeated
words and single-letter 'words' do not count.

Hint text, the rejection message and the README are updated to match.
site/index.html and SHA256SUMS.txt are the rebuilt artifacts.
… per release, hygiene

- SECURITY.md and site/.well-known/security.txt (RFC 9116): both sister
  projects had them; the one tool here that holds other people's secrets
  did not. A weekly workflow fails 30 days before security.txt expires.
- release.yml: a vX.Y.Z tag publishes ittybitz.html, ittybitz-recovery.html
  and SHA256SUMS.txt. It refuses unless the tag, package.json and the
  footer agree, site/ is reproducible from source, this version has left
  fixtures, and the regression suite is green; signs both HTML files'
  provenance through Sigstore (verifiable with gh attestation verify, the
  one check that does not depend on trusting this repository); then
  fetches the latest-download links to confirm they serve the released
  bytes.
- scripts/add-release-fixtures.mts (npm run fixtures:add / fixtures:check):
  encrypts two fixed plaintexts, with and without the key file, using the
  crypto core extracted from the SHIPPED site/index.html and appends them
  under package.json's version. Idempotent; never touches an existing
  entry. Fixtures had stopped at v2.7.3; v3.0.11's four are added here.
- dependabot.yml: the npm section referred to a Next.js/TypeScript setup
  that no longer exists (no dependencies, no lockfile); github-actions
  only now. package.json license: GPL-3.0-only.
site/ittybitz-recovery.html carried a hand-maintained copy of the
decrypt core. The regression suite caught drift, but every change to the
container format or key derivation had to be made twice (the NFC
normalization was the latest). Now there is one source:

  scripts/build/crypto-core.js     format + key derivation + decrypt
  scripts/build/crypto-encrypt.js  encrypt, appended for the app only

build-app.mjs assembles site/index.html as before (the two files above
inside <script id="ittybitz-crypto-core">, so the regression suite's
extraction is unchanged) and now also assembles the recovery tool from
recovery-head.html + crypto-core.js (<script id="ittybitz-decrypt-core">)
+ recovery-app.js. It refuses to build if crypto-core.js defines
ittybitzEncrypt, so the recovery tool stays decrypt-only by construction.

What is tested is still the SHIPPED file: the suite extracts the block
from the HTML and replays every fixture through it. 101/101. A second
build is byte-identical. README and the suite's comments updated.
…ing on the AES card

- Both tablists follow the WAI-ARIA tabs pattern: one tab stop each
  (roving tabindex), Left/Right/Home/End move between tabs and activate
  the one landed on; the File/Text panes are tabpanels linked by
  aria-controls/aria-labelledby.
- The key file's bytes get the same best-effort erase the plaintext
  already got, in the handler's finally.
- 'Military-grade encryption' is a marketing phrase the audit history
  has been steadily removing; the card now says what the page does:
  authenticated encryption, key derived by 1,000,000 rounds of PBKDF2.

site/index.html and SHA256SUMS.txt rebuilt; test:crypto 91/91.
…by crypto.ts

The crypto regression suite proves the core; nothing exercised the page
around it. scripts/verify-ui.mts drives both shipped files through their
real controls in headless Chrome, zero dependencies, with src/lib/crypto.ts
running in Node as the independent judge: what the UI encrypts must open
under crypto.ts, and crypto.ts ciphertext must decrypt through the UI.

Covers: CSP pins recomputed for both files, no unsafe-inline, no network
source, SHA256SUMS current; both pages over file:// and http with no
violation, exception or request; weak password refused, strong accepted;
encrypt → Base64 with an IBTZ v1 header that opens under crypto.ts;
password and secret cleared afterwards; decrypt blurred until the eye;
wrong password → generic failure, no output; a v1.0 headerless fixture
decrypts and its seed is recognised (fingerprint 73c5da0a, SeedQR offered);
SeedQR opens blurred as Standard SeedQR, Reveal enables download; Copy
arms a 60 s clear that is skipped if anything else was copied; 20
generated passwords all pass the strength rule; no sideways scroll at
320/390/1440; the recovery tool decrypts a real fixture and carries no
encrypt function; both pages refuse a cross-origin frame.

Every check was confirmed to fail against deliberately broken code: weak
password accepted, password left in the field, output unblurred, clipboard
clear ignoring a foreign copy, frame guard removed, stale CSP pin. Also
run under Linux Chromium via act: 60/60. ui.yml runs it on every push.
… over as a file

An empty key file adds nothing to the key: with 0 bytes appended, the
derived key is the same as with no key file, so a file that LOOKS
protected by a second factor is not. Both pages now refuse it at pick
time (the app also at Encrypt/Decrypt, defensively) and say why.

Decrypting a file's ciphertext pasted into the text box 'succeeded' and
showed replacement marks, which reads as corruption. Both pages now
decode with TextDecoder({fatal:true}); when the bytes are not UTF-8 they
are downloaded as decrypted.bin with a message saying what happened and
to use file mode next time. Nothing is rendered as text in that case.

Verified in the browser on both pages: a 0-byte key file dropped on the
key zone is refused and not picked; a crypto.ts ciphertext of random
bytes in text mode downloads decrypted.bin and shows the message; a
normal text fixture still decrypts afterwards. test:crypto 91/91.
…ng secret prints; reduced motion honoured

- The secret-text field had spellcheck=false but not autocomplete,
  autocapitalize and autocorrect off, which the sister projects set on
  every secret-bearing field: on iOS, predictive text LEARNS what is typed
  into a plain textarea, so a seed phrase entered here could surface as a
  keyboard suggestion later. Both pages' text fields now carry all four.
  The password field moves from autocomplete=off (ignored for password
  inputs by every browser) to new-password, which does suppress autofill.
- The status line is a live region (role=status, aria-live=polite): a
  screen reader now hears 'Weak password', 'Decrypted successfully' and
  'Decryption failed' instead of silence. Same for the recovery hint.
- @media print blanks every field that can hold a secret, the QR and the
  fingerprints: Print and Save as PDF write the screen to a file.
- prefers-reduced-motion: reduce stops the spinner and transitions.

Verified in Chrome with print media emulated: #t, #out, #p hidden,
status visible; attributes present. test:crypto 91/91.
…ator from the built-in BIP-39 list

The password is cleared after encrypting and is the only way back in, so
a typo was discovered when the file was needed, months later. Encrypt
mode now has a second field: red while it differs, green once it
matches, and Encrypt refuses on a mismatch or an empty repeat. Both
generators fill both fields, since a generated password was never typed
and has no typo to catch; the notice to save it carries that weight.

A 32-character random string cannot be remembered or written down
reliably. The page already carries the BIP-39 list, and 2048 = 2^11, so
eleven raw bits index it with no bias: eight words carry 88 bits at zero
cost to the file. Eight is deliberately not a valid mnemonic length, so
no wallet accepts the result as a seed, and the notice says so. Accepted
by the strength rule from seQRets#44 (6+ distinct words, 24+ chars).

Verified in the browser: mismatch and empty repeat each refuse with
their own message and encrypt nothing; a match encrypts and clears both
fields; ten generated passphrases are 8 BIP-39 words each, all distinct,
mirrored into the repeat field, green border; decrypt mode hides both
the repeat field and the Passphrase button. test:crypto 91/91.
Closed issue seQRets#4 asked for bulk import. The main drop zone now takes
several files (drop or browse, input multiple). Each is processed on its
own — its own salt, its own key derivation, its own download under its
own name — and a failure in one does not stop the others: the summary
names what succeeded and what did not. The desc reads 'N files · size'.
A single file behaves exactly as before.

A key file picked months later is just a filename. The key zone now
shows the first 8 hex characters of the file's SHA-256 beside its name,
and the generated key file announces the same fingerprint when it is
downloaded, so the right file can be recognised without revealing
anything about it. The empty-key-file refusal from seQRets#50 is folded into
the same per-file validation.

Verified in the browser: three files encrypted with a key file → three
downloads; the three ciphertexts plus a junk file decrypted → three
correct plaintexts, the junk named as failed, status red; the key
fingerprint shown equals SHA-256 of the bytes. test:crypto 91/91.
From the QR of an encrypted text, 'Print card' prints one page: the QR
at 8 cm, the Base64 in full, and three steps to decrypt it — open
ittybitz.app or the recovery file kept beside the card, scan or type,
enter the password (and the key file, named, if one was used). The
password is never on it, so the card can sit in a safe or with a
notary and be lost or copied without harm. For a safe, a notary or a
drawer, a sheet that says how to open it is worth more than a QR alone.

Only ENCRYPTED text gets the button: a ciphertext is made to be kept on
paper, a decrypted text or a seed never is, and seQRets#51's print rule keeps
hiding those. The card is a print-only section filled when the button
is pressed and emptied on afterprint (with a fallback timer), so the
ciphertext does not linger in the document for a later Save Page As.

Verified in Chrome with print media emulated: the page is replaced by
the card; after afterprint the card is empty and the body class gone;
the button is absent for a decrypted seed's SeedQR. test:crypto 91/91.
… test:ui now measures every text element

--faint #6e6e77 coloured the 13px password hint on the app and the
'(only if one was used)' label on the recovery tool: 3.92:1 against the
card, under the 4.5:1 WCAG AA asks of normal text. #838391 measures
5.2:1 and still reads as the quiet colour it is meant to be.

verify-ui.mts gains a contrast audit for both pages: every visible text
node against its background composited up the ancestor chain over the
page colour, 4.5:1 normal / 3:1 large. Text over a gradient on a close
ancestor (the orange buttons: black on orange, ~8:1 by eye) is skipped
as unmeasurable this way; body's faint radial glow is not treated as a
gradient, or nothing would be measured. 32 + 17 elements measured;
with the old token swapped back in, both pages fail on exactly the two
elements above. 62/62.
…epeat-password field and asserts the mismatch refusal

Merging seQRets#52 onto seQRets#49 showed two things the PRs could not see alone: the
fourth button (Passphrase) pushed the row to 409px at a 320px viewport,
and the harness's encrypt flow never filled the new repeat field, so
Encrypt refused. Two rows of two under 480px; the harness fills the
repeat field when it exists and checks that a mismatch is refused.
@seQRets

seQRets commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Thank you, Dean, for all the work and support you've put into IttyBitz. This review was thorough and genuinely useful.

I'm closing this one without merging. I'm deeply committed to password security, and I'm keeping the core cryptography exactly as it is. I'm also determined to keep the file as small and tight as possible.

I love the changes you've contributed, and several of them are already live in v3.0.12: several files at once, the key-file fingerprint, the printable emergency card, refusing empty key files, keyboard tabs and the accessibility fixes, and the CI reproducibility check. They're credited in the release notes.

Please keep the suggestions coming!

@seQRets seQRets closed this Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants