Skip to content

feat(firmware): add CTAP2 passkey support for 7.16 - #504

Merged
BitHighlander merged 13 commits into
alphafrom
feat/passkeys-7.16
Aug 22, 2026
Merged

feat(firmware): add CTAP2 passkey support for 7.16#504
BitHighlander merged 13 commits into
alphafrom
feat/passkeys-7.16

Conversation

@BitHighlander

@BitHighlander BitHighlander commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Summary

  • fork current alpha, bump firmware to 7.16.0, and add CTAPHID CBOR/CANCEL/KEEPALIVE while retaining U2F
  • implement CTAP 2.0 MakeCredential, GetAssertion/GetNextAssertion, GetInfo, ClientPIN protocol 1, and Reset
  • add ES256 non-discoverable credentials plus four discoverable credentials with a bounded V17-to-V18 storage migration
  • add canonical bounded CBOR parsing, PIN/privacy/cancellation hardening, unit tests, a fuzz harness, and release-gate documentation
  • retire the unshipped clear-sign identity shadow so V18 remains within the V17 flash footprint and the ARM runtime SRAM reserve is preserved

Verification

  • rebased directly on alpha at 0e494d10444b8111ec741126fbfbf3c49db83b28
  • emulator suite: 447/447 firmware, 12/12 board, 18/18 crypto, and 7/7 constant-time tests (484/484 total)
  • release ARM build succeeds, including the runtime SRAM reserve linker gate
  • firmware sections: 572,568 bytes text, 3,428 bytes data, 109,108 bytes BSS
  • firmware binary: 575,996 bytes
  • firmware binary SHA-256: d4d3796094a349a34a1702cd0d9d1a73218997362690a6d751372398a165e163
  • git diff --check passes

Release gates

This is intentionally a draft until:

  • the bootloader anti-rollback/security epoch is advanced before any V18 firmware ships
  • V17-to-V18 migration, downgrade-wipe behavior, and reset are exercised on hardware
  • current browsers and libfido2 registration/authentication are tested on real KeepKey devices
  • the provisional AAGUID is replaced with the production AAGUID
  • FIDO CTAP2 conformance and independent security review are complete

@BitHighlander
BitHighlander force-pushed the feat/passkeys-7.16 branch 2 times, most recently from 91c19af to 9ac79b4 Compare August 21, 2026 23:54
@BitHighlander

Copy link
Copy Markdown
Owner Author

Holding this one — U5 fired on something real

U5 test_active_flash_format_is_v17 is red, and it is the gate working rather than a flaky test. This PR bumps STORAGE_VERSION_LAST(17)STORAGE_VERSION_LAST(18), and U5 exists precisely to make that bump get argued instead of discovered in the field.

Two separate things need answering.

1. Version number 18 is being REUSED for a different layout

V18 already existed once. It carried the clear-sign identity block, and 7.15 reverted the flash format to V17 in 6bebde7b2. docs/security/storage-version-downgrade-policy.md:137 records that the revert deliberately left the old V18/V19 reader/writer functions in the tree as a follow-up not done — dead, but present.

This PR now defines V18 as something else entirely:

// V18 stores passkey state inside V17's 996-byte reserved plaintext area.
#define V18_STORAGE_LEN (1501 + V17_ENCSEC_SIZE)  // 2525

So a blob stamped 18 means one thing to firmware that predates this PR and a different thing after it. Any device that ever ran a pre-revert RC has a V18 blob with the identity layout, and the new reader would parse it as passkey state. Unsigned RCs cannot prove otherwise — that is exactly the class docs/StorageVersionGate.md calls the highest-severity item in the file.

The cheap fix is to take 19 or 20 instead. A version number costs nothing; a collision costs a misparsed storage blob on somebody's device.

2. The bump needs its release argument written down

Per the gate doc, a bump is a deliberate release act. Before U5 gets updated it wants, in the PR body or the SRS:

  • the reader chain — what reads V17 and produces V18, and what happens to a V17 blob that never migrates
  • the anti-rollback story — after a device writes V18, what does installing 7.15 (V17) do to it? A downgrade wipe is normal, but it should be stated, not found
  • the release note wording

Not blocking on my account — I have no objection to passkeys needing storage, and putting the state in V17's reserved plaintext area is a reasonable shape. But I am not merging a storage-format bump to alpha on a red gate whose whole purpose is to stop exactly this from going in unargued.

Happy to make the version-number change myself if you want it moved to 19.

U5 (test_active_flash_format_is_v17) went red on this branch, and it was right
to. The branch bumped the flash format to 18 -- a number that already means
something else.

18 was the clear-sign identity block and 19 was the PIN-KDF migration. Both
were ACTIVE, not merely drafted: git log -S"STORAGE_VERSION_LAST(19)" shows
e109404 making 19 live and 6bebde7 reverting the format to V17 for 7.15.
Any device that ran an alpha build in that window carries a blob stamped 18 or
19 whose layout has nothing to do with passkeys.

Reusing 18 would make this firmware read those blobs as CTAP2 state. Not
refuse them, not wipe them -- parse them, as something they are not. That is
the failure docs/StorageVersionGate.md calls the highest-severity item in the
file, and it is the reason U5 asserts a literal instead of trusting the
compile-time check: STORAGE_VERSION and STORAGE_VERSION_LAST_SHIPPED are two
numbers in the same header, and raising one to make a build compile is exactly
the edit that must not pass silently.

So: the passkey format takes 20. 18 and 19 stay in the ladder -- the enum is
positional, so removing an entry renumbers everything after it -- but there is
deliberately NO reader for either. A blob stamped 18 or 19 falls through to the
default and the device wipes, which is the documented behaviour for an
unrecognised format and is strictly better than misparsing one.

The passkey format itself is unchanged: same layout, same 2525 bytes in V17's
reserved plaintext area, same reader logic. Only the version number it
registers under, and the V18_* -> V20_* symbol names, have moved.

U5 still needs updating with the release argument for the bump (reader chain,
anti-rollback story, release note) -- that is a deliberate release act and
belongs to whoever cuts 7.16, not to this fix.
BitHighlander and others added 9 commits August 21, 2026 19:49
fix(storage): take V20, because 18 and 19 are burned
Brings in the U5 update that argues the bump -- why 20 and not 18, the reader
chain, the anti-rollback wipe, and the drafted release note -- plus U5b, which
asserts formats 18 and 19 have no dispatch case so a burned blob keeps falling
to the wipe path.
The ladder moved to LAST(20) but STORAGE_VERSION in the header stayed 18, so
the static_assert in storage_versions.inc fired and every build failed:

    storage_versions.inc:34:1: error: static_assert failed
      "need to update storage_versions.inc"

Two numbers describe the same fact and both have to move. That is precisely
why U5 asserts a literal from OUTSIDE the header -- a gate that only compares
these two against each other cannot notice when a release moves them together
for the wrong reason.

STORAGE_VERSION_LAST_SHIPPED deliberately stays 17. It is the high-water mark
of what is in the FIELD, and 7.15 shipped V17; it moves in the release commit
that tags 7.16, not here. The assert only needs STORAGE_VERSION >= LAST_SHIPPED
and 20 >= 17 holds.
The V20 change renamed the implementation but not its callers, so
unittests/firmware/storage.cpp still referenced storage_readV18 /
storage_writeV18 / V18_STORAGE_LEN and every emulator build failed with
"use of undeclared identifier".

My omission, and a reminder that a rename is only done when the compiler says
so on every target -- the ARM build passed because it does not compile the unit
tests, so the first thing that noticed was the bitcoin-only emulator leg.

Also updates the test NAMES and prose, which described the passkey format as
V18: PasskeyMetadataV18RoundTrip -> ...V20..., and
V18IgnoresRetiredClearsignIdentityBlock -> V20... . That second one is worth
reading twice: it asserts the passkey format ignores a trailing clear-sign
identity block left by an older layout, which is exactly the collision that
made 18 unusable in the first place.
storage_fromFlash has no default case on purpose -- that absence is what
makes -Werror=switch name any storage version we forget to handle. The
V20 rename left 18 and 19 unlisted with a comment claiming they "fall
through to the default", which was wrong twice over: there is no default,
and the comment is not something the compiler reads. Both ARM legs failed
to build.

Listed explicitly, returning SUS_Invalid, which is the wipe path a blob
in an unrecognised format is supposed to take. No behaviour change from
what the comment intended -- only now the compiler agrees.

Both ARM variants cross-compiled locally in kktech/firmware:v15 before
pushing.
Picks up BitHighlander/python-keepkey#52, which fixes the two gates that
failed this branch:

  - the structured EIP-712 suite gated on requires_message(), which only
    asks whether python-keepkey's OWN bindings define a message. That is
    a property of the pinned submodule, not the firmware, so it passed
    here despite this branch having no eip712_stream.c at all, and four
    tests failed as though the feature were broken rather than absent.
    Now probes the device.

  - test_burned_versions_have_no_reader asserted the ABSENCE of a
    `case StorageVersion_18:` label. storage_fromFlash has no default
    case on purpose, so -Werror=switch requires that label to exist --
    the test and the compiler gate were in direct contradiction. Now
    asserts the property that matters: the labels dispatch to
    SUS_Invalid with no reader behind them.
Merging alpha into this branch broke the ARM link:

  ld: Insufficient runtime SRAM: require 16 KiB stack/heap reserve
      between _ebss and _stack

Passkeys and structured EIP-712 each fit alone; together they did not.
Measured rather than guessed: the reserve was 15,956 B against a 16,384 B
gate, so 428 B short.

Passkeys cost 1,248 B of static allocation. A PasskeyCredential is 212 B
and PasskeyStorage holds four, and the whole struct lives in RAM TWICE --
the live copy in shadow_config and the sector staging copy in flash_temp
-- so the 888 B struct lands as ~1,248 B of .bss.

Dropping MAX_DECODE_SIZE 12 KB -> 11 KB returns exactly 1,024 B, putting
the reserve at 16,980 B with a 596 B margin. Both ARM variants link with
the real gate in place, and no message static-assert fired, so nothing
outgrew the smaller buffer.

Chosen over cutting PASSKEY_MAX_DISCOVERABLE_CREDENTIALS from 4 to 2,
which would have reclaimed a comparable ~848 B. That is a permanent,
user-visible limit -- 4 discoverable credentials is already low against a
YubiKey's 25+ -- whereas the decode buffer is protocol headroom nobody
sees, and fsm.c static-asserts every registered message against it, so
outgrowing it fails the build rather than the field.

The structural headroom, for later and not tonight: canvas_buffer is
16,384 B, one byte per pixel for a MONO display. At 1bpp it needs 2,048.
That single change would give 7.16 room for everything with margin.
…ge gate

Picks up BitHighlander/python-keepkey#53. Two things this branch needs:

The storage-version gate no longer pins V20, so it stops reporting a
failure whose only cause is which firmware branch the emulator was built
from. It derives the ladder, the burned set and LAST_SHIPPED from the
tree under test and is green on both the 7.15 and 7.16 lines.

And the integration job now finishes: 636 passed, 32 skipped, 0 failed in
2m46s, against an emulator built from current firmware. It had been
timing out at 30 minutes with zero assertions run, behind a green check.
Picks up #54, which repoints the three storage-gate sections whose tests
were renamed. The catalog names the test that evidences each requirement,
so a rename orphans its section and the report validation fails even
though every test passes -- which is exactly what this branch was seeing.
@BitHighlander
BitHighlander marked this pull request as ready for review August 22, 2026 18:07
@BitHighlander
BitHighlander merged commit a710bb5 into alpha Aug 22, 2026
17 checks passed
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.

1 participant