Skip to content

Merge dev into master - #174

Open
dangfan wants to merge 466 commits into
masterfrom
dev
Open

dangfan wants to merge 466 commits into
masterfrom
dev

Conversation

@dangfan

@dangfan dangfan commented Sep 3, 2026

Copy link
Copy Markdown
Member

Summary

Promote the accumulated dev branch to master.

This range brings together the history-preserving development layers merged in #169 through #173, including:

  • CTAPHID/APDU streaming, shared applet-session scratch, and large-message handling
  • runtime device configuration, storage accounting, and transport hardening
  • PIV AES-192 management keys, attestation, key movement, retry configuration, post-quantum algorithms, and GET RANDOM
  • CTAP authenticator configuration, credential-management updates, and conformance fixes
  • OpenPGP, OATH, NFC, WebUSB, and KBDHID fixes and coverage
  • RSA CRT/ECDH validation and APDU differential replay tooling

History policy

  • dev is 419 commits ahead of master at the time this PR is opened.
  • The restack intentionally preserves original commits, authors, timestamps, and merge topology.
  • Merge with Create a merge commit.
  • Do not squash or rebase this PR.

Validation

The source layers were validated with the native unit suites, focused protocol tests, workflow/shell checks, and APDU replay smoke tests documented in #169, #170, #171, #172, and #173. This integration PR adds no commits beyond dev at e1ee3710d97f2d6350d67fa0937a7ee2974a3e9c.

Harry-Chen and others added 30 commits May 5, 2026 20:43
ctap_process_apdu_cbor_message used to read uint8_t cmd = *req before
checking current_req_src. In the FIDO PKE-backed APDU path the caller
passes capdu->data, which still points at shared_io_buffer even though
the real payload is in PKE. The byte we read was discarded by the
following source-read branch but the deref itself is misleading.
Mirror the structure already used in ctap_process_cbor: only fall back
to *req when no source is bound.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
applet_session_scratch_t aliases ctap_ga / ctap_mldsa / buffer through
a union. ctap_process_cbor uses buffer[APPLET_SHARED_BUFFER_LENGTH] as
the encoder output, while CTAP_get_assertion fields like
ext_hmac_secret_salt_auth and pin_uv_auth_param are still read from ga
after the encoder has begun emitting bytes. The flow is only safe
because those fields physically sit past byte 544 in CTAP_get_assertion.

Add static_asserts so a future field reorder or size change breaks the
build instead of silently letting the encoder overwrite parsed input,
and document the contract next to the union.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three DBG_MSG lines (apdu_output stream sent/total/sw, GET RESPONSE
state dump, GET RESPONSE rejected dump) were added during streaming
bring-up but the values they print are reproducible from the failing
SW alone. Drop the per-chunk happy-path traces, keep the read-failure
condition as a single ERR_MSG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two places release DEVICE_APPLET_SESSION_CTAPHID for the same flow:
CTAPHID_TxReset on stream tear-down, and the call sites in
CTAPHID_Execute_Msg / CTAPHID_Execute_Cbor right after they finish.
device_applet_session_release is idempotent so the redundant call is
harmless, but the contract was opaque. Add comments at TxReset and at
the post-send guard explaining that the explicit release covers only
the inline (non-streaming) path; the stream path is already covered by
TxReset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ctap_process_cbor_stream_source_with_src and its ctap_process_cbor_stream_with_src
wrapper only return 1 (success) or -1 (failure). The CTAPHID dispatch
had a separate stream_ret < 0 branch followed by an unreachable
fall-through that did the same thing. Document the contract on the
prototypes and merge the failure paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small mismatches accumulated after the streaming rework:

- Repository Layout listed fido2-tests/ as if it were tracked, but it
  is gitignored and only checked out by CI. Move it out of the tree
  diagram and add a note pointing to where CI fetches it.
- The rand.h porting contract listed random_buffer as optional, but
  ctap_install / piv_install and virt-card fabrication call it
  directly, so a port that omits it will not boot.
- The Streaming / scratch-space policy did not call out two real
  contracts the code now relies on: per-applet *_acquire helpers are
  bookkeeping flags rather than synchronization primitives, and the
  applet_session_scratch_t union aliases ctap_ga/ctap_mldsa with the
  encoder buffer (now guarded by static asserts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The OpenPGP import paths (ck_parse_openpgp and the streaming
ck_parse_openpgp_stream_update) copied the wire-format X25519 private
key directly into ecc.pri. Wire format is little-endian per RFC 7748,
but ecc.pri is read big-endian by mbedtls_mpi_read_binary inside
K__x25519, so the derived public key was computed against a different
scalar value than the host actually sent.

The PIV import paths already swapped LE→BE but never clamped the
scalar. mbedtls_ecp_mul calls mbedtls_ecp_check_privkey before the
Curve25519 ladder and rejects unclamped scalars; for unclamped inputs
ecc_complete_key returned 0 with a never-written all-zero pub. The
existing test_x25519_public_key_encoding case happened to anchor on
that buggy output.

Apply the swap on the OpenPGP paths to match PIV, and apply the
RFC 7748 §5 scalar clamp on all four (PIV streaming + non-streaming,
OpenPGP streaming + non-streaming) so the key is always well-formed
at the point ecc_complete_key runs.

Tests:
- New RFC 7748 §6.1 round-trip test for both OpenPGP paths in
  test_key.c — fails before the fix (pub stays zero).
- Update test_x25519_public_key_encoding's expected public to the
  canonical X25519(scalar, 9) value computed offline against the same
  imported private.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Core CCID defines bulkin_data and bulkout_data as scalars, but
virt-card/ifdhandler.c declared them extern as [2] arrays and indexed
each access by Lun. Lun==0 happens to land on the real scalar so the
suite worked, but any Lun!=0 would read/write neighbouring globals.
The compile-time array bound also disagreed with the actual storage.

Switch the externs to match the scalar definitions and pass Lun only
where it is meaningful (the bSlot field stamping); add an explicit
(void)Lun in the receive path so the parameter is still acknowledged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ctap_get_assertion declares a stack ecc_key_t key (with a TODO comment
on cleanup) and only memzeros it on the MLDSA path before returning.
Every other return — error returns from CHECK_PARSER_RET /
CHECK_CBOR_RET, missing-credential paths, hmac-secret failures, the
main signing path — leaves up to ~200 B of private key material on
the stack for the next caller's frame to inherit.

Add a tiny ecc_key_cleanup helper and tag the local with
__attribute__((cleanup(...))) so the zeroing happens unconditionally
on scope exit, then drop the now-redundant manual memzero in the
MLDSA branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
oath_poweroff cleared oath_remaining_type and is_validated but left
record_idx untouched. The CALCULATE ALL handler treats record_idx==0
as the entry-point case where it parses the host challenge, then
increments record_idx through the records. If applet preemption (or
any other poweroff) hits mid-iteration the leftover non-zero
record_idx makes the next fresh CALCULATE ALL skip challenge parsing
and resume from somewhere in the middle of the list.

Reset record_idx alongside the other per-session state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ndef_create_init_ndef compared write_file/truncate_file return values
against -1 with `<`, which only catches values strictly less than -1.
LFS error codes happen to all be <=-2 today so the check fires in
practice, but the intent is "any error", and the surrounding code
uses < 0 consistently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inside the rx-queue drain in CTAPHID_Loop, two identical CANCEL
detectors fired on the same five conditions (TYPE_INIT, cmd==CANCEL,
channel.executing, cid match, MSG_LEN==0). The first one runs before
the cid-mismatch ERR_CHANNEL_BUSY check at line 808; if its cid match
fails and state is BUSY (which it always is when executing is set in
this codebase), the cid-mismatch path consumes the frame. The second
detector therefore could only fire in an "executing=1 but state!=BUSY"
configuration, which the code path never produces.

Replace it with a comment that points back to the top-of-loop check
so future readers do not re-add the same defensive duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When device_applet_session_expire fires mid-chain it called
applets_poweroff (which releases the PKE buffer via ctap_poweroff)
but never told src/apdu.c about it. The static fido_capdu_chaining
flags (in_chaining, uses_pke, pke_owner) stayed set, so the next
chained FIDO APDU would skip pke_buffer_acquire, see uses_pke==1,
and call pke_buffer_write on a buffer that no one currently owns —
silently corrupting whichever applet had since taken over PKE.

Expose fido_capdu_reset as apdu_fido_chain_reset and invoke it from
device_applet_session_expire, after applets_poweroff drops PKE.
test_core_helpers stubs the new symbol alongside the other transport
stubs so the helper unit test still links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five small omissions surfaced in PR review:

- APDU_INCOMING_DATA_SIZE (288) is the actual chaining bound; the
  doc only mentioned APDU_BUFFER_SIZE (256) and could mislead readers
  into using the wrong limit in applet bounds checks.
- The apdu_response_source_set / _clear / _active streaming helper API
  was load-bearing but undocumented; describe the contract since this
  PR's PIV / OpenPGP / FIDO large-response work all hangs off it.
- ctap_req_src_t.cancelled is a real second member used for
  cooperative CTAPHID CANCEL but the lifecycle prose only mentioned
  read.
- common.h defines htobe16 too; mention it and call out that no
  16-bit LE helper exists.
- testmode_set_initial_ticks and testmode_err_triggered are part of
  the TEST surface but were missing from the listing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
e67c2e6 added a swap+clamp to ck_parse_openpgp / ck_parse_openpgp_stream_update
on the assumption that OpenPGP X25519 wire is little-endian like PIV. That
was wrong: OpenPGP keytocard sends the scalar as a big-endian MPI per
RFC 4880 (gpg / libgcrypt store all keys BE), pre-clamped host-side. The
existing OpenPGP path stored the bytes verbatim and was correct; my swap
permuted them into a non-clamped form that mbedtls_ecp_check_privkey
rejects, leaving the derived public key all-zero. CI's GPG CV25519 phase
happened to keep working before the bad commit because gpg ships clamped
BE bytes; the new RFC 7748 OpenPGP test added in e67c2e6 happened to
"validate" the broken behaviour because its input was fortuitously
clamped under both interpretations (1/32 chance per random scalar, but
hand-picked to satisfy both).

PIV's wire is in fact little-endian (NIST SP 800-78-5), so the existing
PIV swap is correct; the speculative clamp I added isn't needed because
yubico-piv-tool clamps host-side and CI was already exercising it.

Restore both OpenPGP entry points and the PIV entry points to their
pre-e67c2e6 logic. Replace the over-eager LE-input regression test with:
- test_parse_openpgp_x25519_rfc7748 — uses the RFC 7748 §6.1 Alice
  scalar in clamped BE wire form (what gpg actually sends), asserts
  ecc.pri stores it verbatim and the derived pub matches the canonical
  X25519(scalar, 9) value.
- test_parse_openpgp_x25519_streaming_rfc7748 — same vector, fed to
  ck_parse_openpgp_stream_update in 7-byte chunks.
- test_parse_piv_x25519_rfc7748 — same Alice scalar in clamped LE wire
  form (what yubico-piv-tool sends) through ck_parse_piv, expecting the
  same canonical pub.

Also restore the test_x25519_public_key_encoding expected pub: that
value is the canonical X25519 result for the test's BE wire scalar; my
"corrected" value was wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cmocka coverage of src/apdu.c was 69.8%, with the entire
apdu_response_source_set + apdu_output streaming branch (lines 287-310,
327-336 of apdu.c) untested. Add focused tests:

test/test_apdu.c:
- test_response_source_multi_chunk_get_response: drives a 600-byte
  source through 3 GET RESPONSE rounds, asserts chunk size, SW chain
  (0x61FF / 0x6164 / 0x9000), payload bytes, close-callback bookkeeping,
  and the post-stream "no pending response" rejection.
- test_response_source_tail_restore_on_shared_buffer: stages payload in
  shared_io_buffer with a source that reads from the same buffer; after
  chunk 1 we deliberately stamp a fake SW trailer at the boundary, and
  verify that chunk 2 returns the original bytes (the tail-restore copy
  must have replayed them).
- test_response_source_read_failure_clears_state: a source whose read()
  returns -1 must yield SW_UNABLE_TO_PROCESS and still call close().
- test_response_source_clear_calls_close: explicit clear() calls close
  exactly once, idempotent on subsequent calls.
- test_apdu_output_chaining_aliased_buffer: rapdu.data == sh->data path
  with a 280-byte response in the 288-byte shared buffer, exercising
  the tail-save branch at apdu.c:327-336 that is critical for CCID
  in-place chunking.
- test_fido_apdu_chain_overflow_returns_wrong_length: pumps maximum-
  sized chained FIDO APDUs until the PKE-staged accumulator overflows;
  asserts SW_WRONG_LENGTH and that fido_capdu_reset released PKE.

test/test_piv.c:
- test_piv_cert_chained_read: writes a 600-byte cardholder
  authentication cert via 3 chained PUT DATA APDUs, then drains it via
  GET DATA + GET RESPONSE through piv_process_apdu_message, asserting
  byte-exact reassembly. Covers the PIV state-machine GET DATA / GET
  RESPONSE transitions and the apdu_output non-source chaining path.

test/test_openpgp.c:
- test_openpgp_cert_chained_read: same shape for OpenPGP, using
  TAG_CARDHOLDER_CERTIFICATE PUT DATA + GET DATA, exercising
  openpgp_process_apdu_message and the file-backed cert source.

lcov uplift (cmake unit tests only):
- src/apdu.c            69.8% → 78.0%
- applets/openpgp/...   41.7% → 45.2%
- applets/piv/piv.c     27.9% → 34.6%
- total                 40.4% → 42.0%

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The smoking step manually listed only test_apdu, test_openpgp,
test_oath, test_piv. test_core_helpers and test_key were built but
never run, so the cmocka coverage they produce never landed in the
coveralls report. That alone explains src/common.c dropping from
91.3% to 60.9% (the 0x82 TLV length path now lives only in test_key)
and src/device.c dropping similarly (test_core_helpers covers session
APIs and TLV helpers).

Switch to `ctest --output-on-failure`. add_cmocka_test already calls
add_test(), so any future add_mocked_test(...) is picked up
automatically and we stop relying on the smoking-test list staying
in sync with test/CMakeLists.txt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ck_parse_openpgp and ck_parse_piv were the original buffer-mode
parsers for `7F48` (OpenPGP) and `06/07/08`-tag (PIV) key import.
The streaming variants ck_parse_*_stream_init/update have replaced
them everywhere on the production path (applets/openpgp/openpgp.c
and applets/piv/piv.c only invoke the stream APIs). The non-streaming
versions only stayed alive because test/test_key.c still called them.

Delete the two functions (~190 lines) along with their declarations
in include/key.h. The TLV layout block above the OpenPGP variant is
still useful as documentation for the streaming parser, so move/keep
it. Tests are folded onto the streaming API:

  - test_parse_openpgp_x25519: removed; the RFC 7748 variant below
    asserts a stronger property on the same input shape.
  - test_parse_openpgp_x25519_rfc7748: removed; redundant with the
    streaming variant.
  - test_parse_piv_x25519_rfc7748: rewritten to feed bytes through
    ck_parse_piv_stream_update in 5-byte chunks.

Update AGENTS.md to describe the streaming-only contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cmocka tests targeting code that the Python/PCSC integration
suite does not exercise:

- test_algorithm_information walks the response of GET DATA P2=0xFA,
  the only callsite for add_all_algorithm_info / add_algo_info
  (~50 lines in applets/openpgp/openpgp.c). It checks the SIG/DEC/AUT
  per-tag entry counts and verifies each entry is well-formed
  (tag + length + algo-id-prefixed OID).

- test_piv_get_metadata_extended_algo_ids drops a freshly generated
  asymmetric key of each extended type (ED25519, X25519, SECP256K1,
  SM2) into the PIV AUTH slot and reads metadata back, asserting the
  algorithm byte matches the alg_ext_cfg defaults populated by
  piv_install. This covers the X25519/SECP256K1/SM2/ED25519 arms of
  applets/piv/piv.c::key_type_to_algo_id, which integration only
  reaches for RSA2048 / SECP256R1 / SECP384R1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CTAPHID applet step runs fido-hid-over-udp in the background and
ends with `kill "$pid"`, which sends SIGTERM. SIGTERM's default action
is "terminate" — atexit handlers do NOT run, so the gcov runtime never
gets to call __gcov_dump() and the in-memory line counters are
discarded. As a result, every code path the simulator exercised during
test_mldsa65 (ML-DSA make_credential branch in ctap.c, the streaming
callbacks, get_assertion ML-DSA branch) showed up as 0 hits in
coveralls, even though the test ran and passed.

Verified locally: without the handler, SIGTERM yields zero .gcda
files; with it, 107 files (2840 bytes for ctap.c) are written.

Install handlers for SIGTERM and SIGINT that call exit(0). The C
runtime then runs atexit hooks, including the gcov dump, before
process teardown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
exit(0) makes a SIGTERM-killed simulator look like a clean voluntary
return, so callers cannot distinguish "ran to completion" from "got
killed". Switch to the POSIX-conventional 128+signo (143 for SIGTERM,
130 for SIGINT). Functionally identical for our gcov flush — exit()
with any code triggers atexit hooks — but keeps the signal-termination
signal observable to anyone who cares.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keep YubiKey-compatible HMAC-SHA1 commands in the OATH applet over APDU/CCID and preserve the existing KBDHID eject path.
Add a vendor PIV extension to read/set a UTF-16LE name (at most 78
bytes) per asymmetric key slot, stored as a LittleFS attribute.
Replacement clears the name before writing the new key; move carries it;
reset deletes it for ordinary slots.

Also includes internal refactors: deduplicated fs close/error paths,
unified EC public-key length encoding, length-driven PIV policy parsing,
mask-based admin config flag updates, and pointer-based admin config
access.
Add write_file_attrs() and set_attrs_commit() so callers can commit
file data and/or several user attributes in a single atomic commit
through lfs_file_opencfg, reusing the shared file buffer.

write_file_attrs() opens with LFS_O_WRONLY | LFS_O_CREAT (optionally
LFS_O_TRUNC), writes from offset 0 when len > 0, and commits the listed
attributes together with the data. set_attrs_commit() opens an existing
file with LFS_O_WRONLY only, writes nothing, and commits the attribute
batch on close; a missing file fails and is never created.

Both helpers validate arguments before open (file/attr size limits,
attr_count >= 0, non-NULL attrs/buffers for non-zero sizes) and return
LFS_ERR_INVAL without touching the storage on violation. Error handling
follows close_file_result(): open failure returns immediately, every
post-open path closes, a prior write error is preserved, otherwise the
close error is returned, and success is never reported when the closing
commit failed. Existing attributes not listed are preserved, and
zero-length attributes are legal.

On storage error the outcome is uncertain: after a remount the file is
either the old version or the complete new version, never a mix. The
new tests in test_key.c cover batched commits, pre-open validation,
error propagation, and block-device fault injection (prog/erase/sync,
including ops that execute but report failure) with fresh-remount
recovery checks for existing and newly created files.
feat(fs): one-shot attr-batched write helpers
ck_write_key() used two commits per write: one for the key material
and one for the KEY_META_ATTR attribute. Add ck_write_key_attrs(),
which merges the material, the metadata attr, and optional caller
attributes into a single write_file_attrs() commit; ck_write_key()
becomes its NULL wrapper and existing callers are unchanged.

piv_replace_asymmetric_key() now commits the new key together with a
zero-length PIV_CONTAINER_NAME_ATTR instead of removing the name in a
separate commit first, so replacing an existing key no longer pays an
extra clear-name commit. The zero-length name attr is written on every
replacement deliberately: a newly installed key can never inherit the
old name, and the attr then exists permanently with length 0 (slightly
larger directory entry, intentional).

Error contract: on storage error the outcome is uncertain; after a
remount the slot holds either the old key with its old name or the
complete new key with no name, never a mix.
pin_create() now commits the PIN data together with RETRY_ATTR and
DEFAULT_RETRY_ATTR in a single write_file_attrs() commit (was three
commits on an existing file). pin_set_retries() commits both counters
in one set_attrs_commit(); a missing file still fails with PIN_IO_FAIL
and is never created. pin_update() and pin_clear() read the default
retry counter first (read failure still returns PIN_IO_FAIL) and then
commit the data update and the RETRY_ATTR reset together.

pin_verify() keeps the comparison and failure-decrement flow unchanged.
On success it reads DEFAULT_RETRY_ATTR into a separate variable and
only writes RETRY_ATTR back when the current counter differs, so a
successful verify at default retries performs no flash writes at all.
is_validated is now set only after verification and any needed retry
persistence succeed: an error return no longer leaves the PIN
validated. All exits holding pin_buf still memzero it.

The new test_pin_batched_retry_updates asserts via block-device
counters that a default-retry successful verify performs no prog/erase,
that the restore after a failed verify does commit, and that write
failures, blocked/missing PINs, and missing attrs preserve the
existing error semantics without setting is_validated.
openpgp_get_data() read all three key metadata attributes before the
tag switch, but only TAG_APPLICATION_RELATED_DATA and TAG_KEY_INFO
consume them. Move the reads into a small helper invoked at the top of
those two cases (the switch makes them mutually exclusive, so a single
command still reads at most once); every other GET DATA tag now does no
key-metadata reads. Error handling is unchanged: the first failed read
still aborts with -1.
Add fs_reader_t, a scoped read-only file reader for synchronous record
scans: fs_reader_open/size/read_at/close, with read_at returning the
actual byte count so callers can check complete records. The reader
reuses the long-lived file_config/file_buffer shared by all wrappers,
so it owns that cache from open to close; close is idempotent and
resets the reader to its zero state.

To make the 'no nested file ops while a reader is open' rule
executable, TEST builds track shared-cache ownership: every fs.c entry
that uses file_buffer (read_file, write_file/append_file/truncate_file
via write_file_at, get_file_size, write_file_attrs/set_attrs_commit via
opencfg_attrs_close) plus fs_format/fs_mount borrows on entry and
releases on every exit; a nested borrow fails with LFS_ERR_INVAL and
sets a conflict flag queryable via fs_cache_conflict(). The reader
holds ownership from a successful open until close; size/read_at verify
ownership without re-acquiring. Without TEST the checks compile out and
behavior is unchanged.

test_key.c gains reader lifecycle coverage (offsets, short EOF reads,
double/zero-init close), rejection of nested cache users and of a
second reader while one is open, cache release after a failed open, and
cache release on wrapper error exits (injected write error, seek
error).
fs.c gains a mutation generation counter, advanced at the entry of
every public operation that may modify the filesystem (data writes,
attr write/remove, file removal, rename, format, mount), success or
failure — a failed write may still have compacted. Read-only
operations and the fs_reader API do not advance it. Compared for
equality only; wraparound is harmless. Internal helpers
(write_file_at, opencfg_attrs_close) are reached via exactly one
public entry and do not double-count.

ctap_capacity_remaining_new_credentials() previously ran a full
lfs_fs_size scan on every authenticatorGetInfo. It now caches
{value, generation, valid} and recomputes only when fs_generation()
changed; a failed computation (get_fs_free_bytes error) is not cached,
so the next call retries. TEST builds expose
ctap_test_capacity_compute_count() to observe recomputations.

test_key.c asserts the generation advances exactly once per mutating
entry (including injected-failure and mid-commit-failure writes),
stays put on reads and reader operations, and advances on
format/mount. test_apdu.c drives the real capacity path: read-only
queries compute once, an unrelated write invalidates, an injected
failed write invalidates, a remount invalidates, and a read-failed
computation is retried without an intervening write. Both test mains
now hand littlefs static work buffers so mid-suite remounts do not
leak.
ndef_toggle_read_only() updated current_cc in RAM before writing the
CC file, so a failed write left the cache disagreeing with the disk.
On storage error the commit outcome is uncertain: build the candidate
CC, write first, update current_cc only on success, and invalidate the
cache on failure instead of assuming the old state survived.

Add a validity flag and a single load entry ndef_cc_ensure(): only a
complete CC_LENGTH read establishes a valid cache, a failed load stays
invalid and never caches an error, and every consumer of current_cc
(READ CC, the NDEF read/write permission checks, ndef_is_read_only())
goes through it. When permission data is unavailable, reads and writes
fail with an error and ndef_is_read_only() conservatively reports
read-only; no stale permission is ever served. ndef_install()/reset
invalidate the cache first and re-establish it only after a complete
load or a successful write.

Tests live in a new test_ndef target (the suite had none): toggle and
read-back, a commit that lands on disk but reports an error (final
prog executed, then LFS_ERR_IO) followed by reload observing the
applied write, reload failure rejecting permission-dependent
operations without serving stale state, recovery on a later successful
reload with no write, and install re-establishing the cache after the
CC file went missing.
READ BINARY on the CC file is served from current_cc via
ndef_cc_ensure(): a cache hit is a plain memcpy with zero flash access,
and the existing offset/length checks are unchanged. An invalid cache
falls through to one flash reload that re-populates it. NDEF data file
reads are unchanged.
Consolidate the fs write paths (write_file/append_file/write_file_attrs/
set_attrs_commit) onto one write_attrs_at helper and share seek_and_read
between read_file and fs_reader_t; merge pin_get_retries/
pin_get_default_retries into pin_get_counter; simplify the NDEF CC toggle
to invalidate-on-failure without a candidate copy and restructure
ndef_read_binary around the cached-CC early return.

Partially offsets the NFCC flash cost of the merged-commit series.
The dispatch read the terminated attribute from flash on every APDU.
The flag is only written by install and terminate, so keep a RAM copy:
loaded on first use, written through on success, and invalidated on a
failed write because the commit outcome is uncertain. Saves one
attribute read per OpenPGP command.
- drop __packed from config_page_t: fields already follow their natural
  alignment (static asserts pin the on-flash layout), so Thumb-1 helpers
  use word accesses instead of unpacking bytes
- check SN write-once against the validated snapshot in config_update
  instead of rereading the page
- fold the flag readers into config_read_flag; drop unused config_valid
The key-handle wrapping key was re-read from flash for every
allow/exclude-list entry verification and every key handle generation.
Cache it in RAM: invalidated at the top of ctap_install (its sole
production writer) so resets and reconnects always reload the committed
key, loaded lazily on first use, and a failed load leaves the cache
invalid so the next use retries.

Also reject truncated or oversized KH_KEY attributes with
LFS_ERR_CORRUPT instead of silently using a partially filled key.
perf(ctap): cache KH_KEY in RAM
SP 800-73-4 Part 2 §3.1.1 requires that re-selecting the PIV Card
Application (full or right-truncated AID) leaves all security status
indicators unchanged; they may only be cleared when the selected
application changes. piv_select() previously reset the PIN, PUK and
management-key authentication on every SELECT.

Drop the resets from piv_select(); the applet-switch path already
clears everything via piv_poweroff(), which now also resets the
management-key mutual-auth challenge (previously only cleared by
piv_select) to keep the switch-away semantics unchanged. Streaming
and GA stream state were already reset by the INS checks in
piv_process_apdu().

Add test_piv_reselect_preserves_security_status covering re-select
with the full and the RID-only AID, and the cross-applet switch that
must still invalidate PIN verification.
The release-version centralization made piv_get_version report the
build-configured CANOKEY_PIV_VERSION bytes (0.0.0 in development
builds) instead of a hardcoded 6.0.0. Compare against the generated
firmware-version.h macros; the SW-trailer restoration regression this
test targets does not depend on the specific version value.
Acquire the applet session before staging large HID requests in PKE and retain ownership through fragmented reception. Release receive ownership on abort, timeout, resynchronization and PING completion; hand MSG/CBOR ownership to their command handlers.

Share CCID response-header construction and HID setup handling without changing compiler flags. Add wire-response and request-lifetime regressions.

Validation: 94 test_apdu tests pass; DevKit and NFCC Release builds pass. NFCC Flash is 163728 bytes, saving 108 bytes against the session-fix baseline. The session fix was also verified on the flashed DevKit with CCID/HID interleaving.
Apply the optional restriction to GetInfo, registration, and existing credential authentication while preserving credential management. Cover advertised algorithms, registration fallback, and assertions in both build modes.
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