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
17 changes: 17 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
# Cargo dependencies are intentionally NOT managed here — they are covered by
# cargo-audit (advisories), cargo-deny (advisories + licenses) and cargo-vet
# (provenance-of-review) in the merge gate.
#
# The Python host tooling (tools/) IS watched here via the native `uv` ecosystem
# (Dependabot reads pyproject.toml + uv.lock directly; GA 2025-03, security
# updates 2025-12) — so a future cryptography/fido2/pyscard/pytest CVE lands as a
# PR instead of just a quiet alert, the way the 2026-06 cryptography/pytest CVEs
# did. One grouped PR per week.
version: 2
updates:
- package-ecosystem: github-actions
Expand All @@ -21,3 +27,14 @@ updates:
commit-message:
prefix: ci
open-pull-requests-limit: 5

- package-ecosystem: uv
directory: "/tools"
schedule:
interval: weekly
groups:
python:
patterns: ["*"]
commit-message:
prefix: deps
open-pull-requests-limit: 5
39 changes: 39 additions & 0 deletions .github/workflows/rekor-monitor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,46 @@ concurrency:
cancel-in-progress: false # never abort a run mid-checkpoint

jobs:
# Self-heal the "death spiral". rekor-monitor persists its log position as a
# `checkpoint` GH artifact. If a run is cancelled by the reusable workflow's
# 60-min job timeout BEFORE it uploads a fresh checkpoint, the stale one is
# reused forever and every later run re-scans an ever-growing slice of the
# (~1.75-billion-entry, +400k/hr) Rekor log → another timeout → the monitor is
# wedged until the artifact is deleted by hand (happened 2026-06-20). This caps
# the damage at ONE cancelled run: if the previous run did not finish, drop the
# stuck checkpoint so the monitor below re-seeds to the current log size. The
# step is best-effort and always exits 0 — it must never block the monitor.
preflight:
runs-on: ubuntu-latest
permissions:
actions: write # read run conclusions + delete the stuck checkpoint artifact
steps:
- name: Reset a stuck checkpoint after a cancelled run
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
last=$(gh api "repos/$REPO/actions/workflows/rekor-monitor.yml/runs?status=completed&per_page=1" \
--jq '.workflow_runs[0].conclusion // "none"' 2>/dev/null || echo "none")
echo "previous completed rekor-monitor run: $last"
case "$last" in
cancelled | timed_out)
echo "::warning::previous run did not finish — deleting the stuck checkpoint so the monitor re-seeds"
gh api "repos/$REPO/actions/artifacts?name=checkpoint&per_page=100" \
--jq '.artifacts[].id' 2>/dev/null |
while read -r id; do
gh api -X DELETE "repos/$REPO/actions/artifacts/$id" 2>/dev/null &&
echo "deleted checkpoint artifact $id"
done
;;
*)
echo "checkpoint healthy — nothing to reset"
;;
esac
exit 0

monitor:
needs: preflight
permissions:
contents: read # checkout the reusable workflow
issues: write # file an issue when a watched identity appears
Expand Down
186 changes: 186 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,192 @@ tag: the USB `bcdDevice` build counter (bumped on every behavior change), and

## [Unreleased]

## [0.2.5] — 2026-06-20

### Added

- **Runtime LED hardware config — pin, driver, and wire order are now set at
runtime via the `phy` record (`rsk hw` / PicoForge), no reflash.** The
`LED_KIND` / `LED_PIN` / `LED_ORDER` build knobs (below) become *boot
defaults*: a non-`none` build now compiles all three backends and, at boot,
applies the data pin (`led_gpio`), driver (`led_driver` — 1=gpio / 2=pimoroni /
3=ws2812, matching pico-fido / PicoForge), and an RS-Key vendor wire-order tag
(`led_order`, `0x0D`) from `EF_PHY` — the same record that already drives the
USB identity. The pin reaches the PIO state machine through a `match` over GPIO
`0..=29` (embassy has no `PioPin for AnyPin`, but doesn't need one); the wire
order is a runtime red/green swap, so one binary serves both RGB- and GRB-wired
parts. New **`rsk hw`** command (`--led-pin` / `--led-driver` / `--led-order` /
`--get`) does a read-modify-write of only the LED fields (any USB identity is
preserved) and warm-reboots to apply. A `none` build stays headless and ignores
the phy LED fields. `bcdDevice` `0x077A` → `0x077B`.

- **Selectable LED backend (`LED_KIND` build knob) — the indicator is no longer
WS2812-only.** The status engine (boot/processing/touch/idle blink + the
runtime-configurable colour/brightness in `EF_LED_CONF`) was already
backend-agnostic; only the render half was hard-wired to the Waveshare's
addressable WS2812. The render is now chosen at build time: `ws2812` (default —
the addressable RGB on `LED_PIN`), `gpio` (a plain on/off LED on `LED_PIN`;
hue/brightness collapse to lit/unlit, but the blink *pattern* still tells the
statuses apart — so RS-Key now runs on boards with a simple LED, e.g. a bare
RP2350 or Pico 2), `pimoroni` (a 3-pin PWM common-anode RGB, Pimoroni Tiny 2350)
or `none` (headless). Only the selected driver and its PIO/PWM dependencies are
compiled. `bcdDevice` `0x0778` → `0x0779`.

- **`LED_ORDER` build knob — the WS2812 wire byte order is now selectable.** The
reference Waveshare RP2350-One is unusually **RGB**, the project default; but
standard WS2812B parts (e.g. the TenStar RP2350-USB) are **GRB**, and driving
one with the wrong order swaps red↔green (blue is unaffected). `LED_ORDER=grb`
picks the standard order for such boards; `rgb` (default) keeps the Waveshare
behaviour. Verified on a TenStar RP2350-USB (16 MB, WS2812 on GP22):
`LED_KIND=ws2812 LED_ORDER=grb LED_PIN=22 FLASH_SIZE=16M`. `bcdDevice` `0x0779`
→ `0x077A`.

- **Hybrid post-quantum seed-backup channel — the vendor MSE key agreement is now
P-256 + ML-KEM-768.** The seed-backup channel (`authenticatorVendor` `0x41`,
`MSE`) is the one place the device hands out a normally non-exportable key — the
32-byte master seed — so a recorded exchange is the prime harvest-now-decrypt-
later target: break the ephemeral P-256 ECDH with a future quantum computer and
the wrapped seed falls out. The handshake now accepts an optional ML-KEM-768
(FIPS 203) encapsulation key in subCommandParams key 2; when present the device
encapsulates to it and derives the channel key as
`HKDF-SHA256("RSK-MSE-PQ-v1", z ‖ ss_mlkem, dev_pub ‖ ct)`, returning the
ciphertext as response key 2. Both shared secrets feed the KDF, so the channel
stays confidential unless *both* P-256 and ML-KEM-768 are broken (defense in
depth — never PQC-only). Only the cheap `encapsulate` direction runs on-device;
the host keeps the ML-KEM keypair and decapsulates. A host that sends no key 2
gets the classical channel byte-for-byte, so existing hosts keep working.
`bcdDevice` `0x0777` → `0x0778`.

- **`alwaysUv` (always require user verification) is supported.** `getInfo`
advertises the `options.alwaysUv` flag (reflecting its state, `false` at reset)
and the `toggleAlwaysUv` (`0x02`) `authenticatorConfig` subcommand. While enabled
(flipped via `authenticatorConfig` toggleAlwaysUv, gated on a pinUvAuthToken with
the `acfg` permission), every `makeCredential` / `getAssertion` requires a verified
pinUvAuthToken — an up-only (touch) request is refused with
`CTAP2_ERR_PUAT_REQUIRED`, even when no PIN is configured. The state persists until
`authenticatorReset`, which clears it. Completes the FIDO conformance "featureful"
CTAP2.3 profile's authenticatorConfig requirement. `bcdDevice` `0x0774` → `0x0775`.

- **`getInfo` advertises five optional informational members.** `transports`
(0x09, `["usb"]`), `maxRPIDsForSetMinPINLength` (0x10, `8`),
`remainingDiscoverableCredentials` (0x14, the live free resident-key-slot count),
`attestationFormats` (0x16, `["packed"]`) and `maxPINLength` (0x1D, `63`). Purely
informational — no behaviour change — and mirrored in the metadata statement (the
FIDO conformance Authr-Generic test strict-compares each member to it).
`bcdDevice` `0x0776` → `0x0777`.

### Fixed

- **CTAPHID: an init-type frame received mid-transaction is rejected as
`ERR_INVALID_SEQ` regardless of its length field.** The `bcnt > maxMsgSize`
check ran first, so a continuation frame whose sequence byte had the INIT bit
set — the FIDO Conformance Tools' `HID-1 F-4` corrupts the last frame's seq to
`CTAPHID_PING + 1` (0x82), leaving random payload bytes as the "bcnt" — usually
tripped the length guard and returned `ERR_INVALID_LEN` (0x03) instead of the
required `ERR_INVALID_SEQ` (0x04). The out-of-sequence check now precedes the
length check. `bcdDevice` `0x0767` → `0x0768`.
- **U2F authenticate resolves the key handle before requesting a touch.** An
unknown handle (wrong AppID / not minted by us) and a check-only (`P1=0x07`)
request must be answered immediately — `0x6A80` and `0x6985` respectively —
without user presence; we prompted for a touch first on `P1=0x03`, so a
conformance negative test (`U2F-Authenticate F-2`) hung on the button and the
stream of `UPNEEDED` keepalives desynced the tool's response reader (seen as
"sequence out of order"). Shares the `0x0768` bump.
- **No `PROCESSING` keepalive before a fast U2F response.** U2F (CTAPHID_MSG) is
quick apart from the touch wait, but the worker runs on a lower-priority
executor, so the 100 ms keepalive timer could fire once before a near-instant
reply (check-only, unknown handle) — and U2FHID hosts, including the FIDO
Conformance Tool, read that stray `PROCESSING` frame as the response's first
frame and desync (`U2F-Authenticate P-3`/`F-2`: "sequence out of order"). MSG
now stays silent unless a touch is pending (`UPNEEDED`); CBOR keeps
`PROCESSING` for its genuinely slow operations. `bcdDevice` `0x0768` →
`0x0769`.
- **`CTAPHID_CANCEL` aborts an in-flight request's user-presence wait.** While
the worker blocked on the touch wait the transport never read further frames,
so a `CANCEL` sat unread until the (up to 30 s) wait ended — the FIDO
Conformance Tool's `HID-1 P-10` (cancel during `makeCredential`) and `P-15`
(cancel during `authenticatorSelection`) timed out. The transport now watches
for a `CANCEL` on the active channel concurrently with the worker and signals a
cross-executor abort; the cancelled command returns `CTAP2_ERR_KEEPALIVE_CANCEL`
(0x2D). A `CANCEL` is also no longer acknowledged with its own frame (per the
CTAPHID spec).
- **`authenticatorMakeCredential` input validation.** A non-text `rp.name`
(`Req-2 F-2`) and a `pubKeyCredParams` entry missing its `alg` (`Req-4 F-4`)
are now rejected instead of accepted.
- **`authenticatorMakeCredential` accepts `options.up=true`.** An explicit
`up=true` is the default and now succeeds (`Req-6 P-3`); only `up=false`
remains an `INVALID_OPTION` (`F-1`).
- **getAssertion withholds user name/displayName without user verification.** On
a multi-credential discovery the response `user` map now carries only `id`
unless `uv` is set (CTAP §6.2.2 privacy rule, `Discoverable P-2`); the full
identity is returned once the user is verified. Applies to
`authenticatorGetNextAssertion` too.
- **credentialManagement enumerateCredentials always reports `credProtect`.** The
`0x0A` field was emitted only when a non-default level was set; it now always
appears, defaulting to level 1 (`userVerificationOptional`)
(`CredMgmt-EnumerateCredentials P-1`).
- **largeBlobs accepts `get=0`.** A read of zero bytes is valid and returns an
empty fragment instead of `CTAP2_ERR_INVALID_PARAMETER` (`LargeBlobs-1 P-2`).
- **credentialManagement updateUserInformation keeps the credentialId stable.**
Resealing a credential draws a fresh IV (nonce reuse is forbidden), so the box —
and the resident id previously re-derived from it — changed, staling the
platform's stored credentialId; a later `deleteCredential` with that id then
returned `CTAP2_ERR_NO_CREDENTIALS` (`CredMgmt-UpdateAndDelete P-2`). The update
now rewrites the credential in place, preserving its stored 42-byte resident id,
and `getAssertion` returns that stored id instead of re-deriving it (CTAP2.1
§6.8.5). The signing key / hmac-secret / largeBlobKey are still box-derived, so
they rotate on an update — full stability needs a per-credential nonce and is
deferred. `bcdDevice` `0x076F` → `0x0770`.
- **A `pinUvAuthToken` request while a forced PIN change is pending now returns the
correct per-subcommand error.** With `forcePINChange` set (via `setMinPINLength`
subcommand param `0x03`), both `getPinToken` (0x05) and
`getPinUvAuthTokenUsingPinWithPermissions` (0x09) refuse to issue a token until the
PIN is changed. The FIDO conformance ClientPin forcePINChange tests assert a
*different* code for each: legacy `getPinToken` (0x05) → `CTAP2_ERR_PIN_INVALID`
(0x31) (`ClientPin1-NewPin F-1`, `ClientPin2-GetPinToken F-5`); the
permissions-based `getPinUvAuthTokenUsingPinWithPermissions` (0x09) →
`CTAP2_ERR_PIN_POLICY_VIOLATION` (0x37)
(`ClientPin2-GetPinUvAuthTokenUsingPinWithPermissions F-1`). Previously both
returned `PIN_POLICY_VIOLATION`. The PIN verify itself still succeeds first, so the
retry counter is untouched. `bcdDevice` `0x0773` → `0x0774` (0x05 fix); the 0x09
branch followed at `0x0776`.

### Changed

- **Enterprise attestation: `ep` advertised + reflects state, type-1 eligibility
enforced.** `getInfo` and the metadata statement carry the `ep` option (`false`
until `authenticatorConfig` enableEnterpriseAttestation flips it `true`), so
platforms and the conformance tool exercise the enterprise profile. EA is now
performed only when warranted — platform-managed (type 2) for any RP,
vendor-facilitated (type 1) only for an RP on a built-in list (empty in shipping
firmware). Any enterpriseAttestation request now yields a basic_full (x5c)
attestation: the org/EP cert + `epAtt` when EA is performed, or a non-enterprise
basic_full with the device's own cert and no `epAtt` for a non-listed type-1 RP
(CTAP2.1 §6.1.3, conformance Enterprise-Attestation F-6, which requires x5c). A
request without enterpriseAttestation keeps the default self-attestation. The FIDO
conformance test RPID is added to the type-1 list **only** under the
conformance-only `ea-conformance-rpid` build feature, never in a shipped image.
The metadata `upv` gains `{1,2}` and `{1,3}` and drops the non-MDS3
`legalHeader`. `bcdDevice` `0x0770` → `0x0772`.
- **EdDSA (-8) and ES256K (-47) are no longer advertised in `getInfo.algorithms`
or the metadata.** The FIDO conformance tool's shared `verifySignatureCOSE` maps
only `-7`/`-35`/`-36` for elliptic curves, so it throws "hashFunction missing"
verifying a packed self-attestation over an EdDSA or secp256k1 credential
(`MakeCred-Resp P-06`). Both stay fully implemented — makeCredential negotiates
`-8`/`-47` from a request's `pubKeyCredParams` — only the advertisement is dropped
(the same approach as ML-DSA-44), leaving the advertised set at the
tool-verifiable NIST curves ES256/ES384/ES512. getInfo, `authenticationAlgorithms`
and `authenticatorGetInfo.algorithms` kept in sync (`tests/62`). `bcdDevice`
`0x0772` → `0x0773`.
- **`getInfo` advertises the `authenticatorConfigCommands` member (`0x1F`).** It
lists the supported `authenticatorConfig` (0x0D) subcommands —
`enableEnterpriseAttestation` (0x01), `toggleAlwaysUv` (0x02) and `setMinPINLength`
(0x03). The FIDO conformance AuthenticatorConfig suite requires it (the
enable-enterprise-attestation test asserts the array contains `0x01`, the
"featureful" CTAP2.3 profile requires `0x02`, and the suite's `before` hook reads
it). Mirrored in the metadata statement. Shares the `0x0774` bump (`0x02` arrived
with alwaysUv at `0x0775`, below).

## [0.2.4] — 2026-06-19

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ replacement for an audited commercial key.
## Project status

A working, single-maintainer hobby project under active development. The latest
tagged release is **v0.2.4**; day to day the supported version is the tip of
tagged release is **v0.2.5**; day to day the supported version is the tip of
`main`, and every behavior change bumps the USB `bcdDevice` build counter so a
build can be named precisely. Most of the protocol surface works against real host software; what
has actually been checked on hardware (with dates) is in
Expand Down
26 changes: 26 additions & 0 deletions crates/rsk-crypto/src/mlkem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,30 @@ mod tests {
// An all-0xFF key has non-reduced coefficients → InvalidKey.
assert!(mlkem768_encapsulate(&[0xFF; MLKEM768_EK_LEN], &[0u8; 32]).is_err());
}

/// Emit a deterministic KAT (fixed `d‖z` seed + fixed `m`) so the host
/// toolchain's ML-KEM-768 (OpenSSL, via `cryptography`) can be cross-checked
/// against this RustCrypto implementation off-device: same seed must give the
/// same `ek`, and the host must decapsulate this `ct` back to this `ss`. Run:
/// `cargo test -p rsk-crypto --target <host> --ignored mlkem_interop_kat -- --nocapture`
#[test]
#[ignore = "prints an interop KAT for the host ML-KEM cross-check"]
fn mlkem_interop_kat() {
fn hex(b: &[u8]) -> std::string::String {
let mut s = std::string::String::new();
for x in b {
s.push_str(&std::format!("{x:02x}"));
}
s
}
let seed = [0x5Au8; MLKEM768_SEED_LEN];
let m = [0x3Cu8; 32];
let pair = MlKem768Pair::from_seed(&seed);
let ek = pair.encapsulation_key();
let (ct, ss) = mlkem768_encapsulate(&ek, &m).unwrap();
std::println!("SEED {}", hex(&seed));
std::println!("EK {}", hex(&ek));
std::println!("CT {}", hex(&ct));
std::println!("SS {}", hex(&ss));
}
}
6 changes: 6 additions & 0 deletions crates/rsk-fido/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,9 @@ advertise-pqc = []
# rises to 6 and the vendor seed export is refused. A build profile, not a
# FIPS validation — see docs/guides/fips.md.
fips-profile = []
# Add the FIDO Conformance Tool's fixed enterprise-attestation test RPID
# (enterprisetest.certinfra.fidoalliance.org) to the vendor-facilitated (type 1)
# EA eligibility list, so the tool's Enterprise-Attestation type-1 case can run.
# Conformance self-validation ONLY — never ship it (it would grant enterprise
# attestation to a FIDO test domain). Shipping firmware's type-1 list is empty.
ea-conformance-rpid = []
Loading
Loading