From 266bf38021802f7566fd4d2eeac589c96920b891 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 11 May 2026 16:13:23 +0200 Subject: [PATCH] Add MuSig2 commands page; document user-auth challenge persistence New page commands_musig2.rst covering INS 0xC7 (NonceGen), INS 0xC8 P1=01 (LoadCosignerKey), INS 0xC8 P1=02 (PartialSign), with the BIP-327 protocol state machine and PIN gating on all three. CHECK USER KEY page updated for the new challenge-persistence behavior: the challenge nonce survives an applet deselect (host can disconnect and re-open a Secure Channel between P1=1 and P1=2), strict single-use on P1=2 (consumed on success and failure), and the allowlist of commands that can run while a challenge is active without invalidating it. Overview table gets a new MuSig2 section. Index toctree updated. --- docs/source/commands_musig2.rst | 313 +++++++++++++++++++++++++++++ docs/source/commands_overview.rst | 32 ++- docs/source/commands_user_keys.rst | 32 ++- docs/source/index.rst | 1 + docs/source/status_codes.rst | 2 +- 5 files changed, 373 insertions(+), 7 deletions(-) create mode 100644 docs/source/commands_musig2.rst diff --git a/docs/source/commands_musig2.rst b/docs/source/commands_musig2.rst new file mode 100644 index 0000000..0237002 --- /dev/null +++ b/docs/source/commands_musig2.rst @@ -0,0 +1,313 @@ +MuSig2 commands (BIP-327) +========================= + +The card implements the on-card half of the BIP-327 MuSig2 multi-signature protocol. MuSig2 +produces a single Schnorr (BIP-340) signature that is indistinguishable from a non-MuSig +signature on-chain, while requiring two communication rounds between cosigners. The card acts +as one of the cosigners holding its share of the aggregate private key; key aggregation, nonce +aggregation and the host-side Taproot tweak are performed off-card. + +The protocol uses three commands run in sequence: + +1. :ref:`NONCE GEN ` (INS 0xC7) — the card generates its own pair of + secret nonces ``(k1, k2)`` and returns the matching public pair ``(R1, R2)``. +2. :ref:`LOAD COSIGNER KEY ` (INS 0xC8 P1=01) — the host sends each + cosigner public key (including the card's own) so the card can build the KeyAgg list hash + ``L`` incrementally. +3. :ref:`PARTIAL SIGN ` (INS 0xC8 P1=02) — the host sends the + aggregate nonce ``(aggR1, aggR2)``, the aggregate public key ``Q``, and the message hash. + The card returns its 32-byte partial signature. + +.. note:: + + The card is curve-fixed on secp256k1 for MuSig2. Only the wallet's k1 key (or the pinless + k1 key if applicable) is usable as the signing key. The card never aggregates the partial + signatures itself --- aggregation is the host's responsibility. + +**Protocol state machine** + +The card enforces the protocol order with an internal state machine. Calls outside the +expected sequence are rejected with ``0x6985``. + +.. list-table:: + :header-rows: 1 + :widths: 25 35 40 + + * - State + - Set by + - Accepts + * - IDLE + - Card reset, after PARTIAL SIGN, after rejected PARTIAL SIGN + - NONCE GEN + * - NONCE_GENERATED + - Successful NONCE GEN + - LOAD COSIGNER KEY, NONCE GEN (restarts) + * - WAIT_COSIGNERS + - First successful LOAD COSIGNER KEY + - LOAD COSIGNER KEY (more), PARTIAL SIGN + +After an applet deselect between LOAD COSIGNER KEY and PARTIAL SIGN, the card auto-demotes +back to NONCE_GENERATED so the host can resend the cosigner list. The secret nonces +``k1, k2`` and the card's own public key snapshot survive the deselect, so NONCE GEN does +not need to be re-issued. + +---- + +.. _cmd-musig2-nonce-gen: + +NONCE GEN +--------- + +**Request APDU** (encrypted) + +.. list-table:: + :header-rows: 1 + :widths: 12 12 12 12 12 40 + + * - CLA + - INS + - P1 + - P2 + - LC + - Data + * - ``0x80`` + - ``0xC7`` + - ``0x00`` + - ``0x00`` + - ``0x00`` + - MAC + +**Preconditions**: Secure Channel opened, seed loaded, PIN or user-key authentication +performed (or pinless path active). + +Generates the card's MuSig2 secret nonce pair ``(k1, k2)`` and returns the corresponding +public points ``(R1, R2)``. Internally the card also stores the compressed form of its own +public key ``pk = sk*G`` so that ``LOAD COSIGNER KEY`` can detect it in the cosigner list and +so that ``PARTIAL SIGN`` can verify the signing key has not changed in between (BIP-327 +requires ``pk == secnonce[64:97]``). + +The secret nonces are derived per BIP-327 ``NonceGen``. Two successive ``NONCE GEN`` calls +always produce different nonce pairs. + +**Response Data** --- 66 bytes + +.. list-table:: + :header-rows: 1 + :widths: 25 15 60 + + * - Field + - Size + - Description + * - R1 + - 33B + - Compressed EC point ``k1 * G`` + * - R2 + - 33B + - Compressed EC point ``k2 * G`` + +.. note:: + + An active nonce pair ``(k1, k2)`` survives an applet deselect. It is zeroed after + ``PARTIAL SIGN`` (successful or rejected), after a full reset, and at the start of any + new ``NONCE GEN`` call. The host is expected to use each nonce pair exactly once; + reusing nonces across two different messages leaks the private key by linear algebra. + The card enforces this by zeroing the nonces after each sign. + +**Status Words** + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - SW + - Description + * - ``0x9000`` + - Success + * - ``0x6985`` + - PIN or user-key auth not performed, or seed not loaded + +---- + +.. _cmd-musig2-load-cosigner: + +LOAD COSIGNER KEY +----------------- + +**Request APDU** (encrypted) + +.. list-table:: + :header-rows: 1 + :widths: 12 12 12 12 12 40 + + * - CLA + - INS + - P1 + - P2 + - LC + - Data + * - ``0x80`` + - ``0xC8`` + - ``0x01`` + - ``0x00`` + - ``0x21`` + - MAC | Encrypted data + +**Preconditions**: Secure Channel opened, PIN or user-key authentication performed (or +pinless path active), state is NONCE_GENERATED or WAIT_COSIGNERS. + +Feeds one cosigner public key (33-byte compressed) into the incremental KeyAgg list hash +``L = sha256("KeyAgg list" || pk1 || pk2 || ... || pkN)``. Must be called once per cosigner, +including the card's own key. The order must match the order the host uses to compute the +aggregate public key ``Q``. + +While processing each key, the card compares it against its own stored ``pk`` snapshot. If a +match is found, an internal flag is raised; ``PARTIAL SIGN`` later requires this flag to be +set, mirroring the BIP-327 ``Sign`` algorithm's check ``pk in pubkeys``. + +**Request Data --- 33 bytes** + +.. list-table:: + :header-rows: 1 + :widths: 25 15 60 + + * - Field + - Size + - Description + * - Cosigner pubkey + - 33B + - Compressed EC point (``02|X`` or ``03|X``) + +**Status Words** + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - SW + - Description + * - ``0x9000`` + - Success + * - ``0x6700`` + - Data length is not 33 bytes + * - ``0x6985`` + - State is not NONCE_GENERATED or WAIT_COSIGNERS, or PIN / user-auth not performed + +---- + +.. _cmd-musig2-partial-sign: + +PARTIAL SIGN +------------ + +**Request APDU** (encrypted) + +.. list-table:: + :header-rows: 1 + :widths: 12 12 12 12 12 40 + + * - CLA + - INS + - P1 + - P2 + - LC + - Data + * - ``0x80`` + - ``0xC8`` + - ``0x02`` + - ``0x00`` + - ``0xC3`` + - MAC | Encrypted data + +**Preconditions**: Secure Channel opened, state is WAIT_COSIGNERS, PIN or user-key auth +performed (or pinless path active), card's own key seen via ``LOAD COSIGNER KEY``. + +Computes and returns the card's partial signature ``s_i`` per BIP-327 ``Sign``: + +.. code-block:: none + + b = tagged_hash("MuSig/noncecoef", aggR1 || aggR2 || Q.x || msg) mod n + R = aggR1 + b * aggR2 + e = tagged_hash("BIP0340/challenge", R.x || Q.x || msg) mod n + a_i = tagged_hash("KeyAgg coefficient", L || pk_i) mod n + s_i = (k1 + b * k2 + e * a_i * sk) mod n + +The card never returns ``R``, ``e``, ``a_i`` or any other intermediate; only ``s_i``. After +the call, the secret nonces ``k1, k2`` and the card's public key snapshot are zeroed, and +the state machine returns to IDLE regardless of the outcome. + +**Request Data --- 195 bytes** + +.. list-table:: + :header-rows: 1 + :widths: 25 15 60 + + * - Field + - Size + - Description + * - aggR1 + - 65B + - Uncompressed EC point (``04 | X | Y``), sum of cosigners' R1 + * - aggR2 + - 65B + - Uncompressed EC point (``04 | X | Y``), sum of cosigners' R2 + * - aggPubKey Q + - 33B + - Compressed aggregate public key (host-computed, must already be tweaked if Taproot) + * - Message + - 32B + - Message hash (typically a Taproot ``sig_msg``) + +**Response Data** --- 32 bytes + +.. list-table:: + :header-rows: 1 + :widths: 25 15 60 + + * - Field + - Size + - Description + * - Partial signature s_i + - 32B + - Scalar ``mod n`` + +.. note:: + + The aggregate public key ``Q`` provided in the request must already include the host-side + Taproot tweak when applicable. The card does not apply BIP-341 tweaks. Sending an + un-tweaked ``Q`` for a Taproot output yields a signature that will not verify under the + on-chain key. + +.. important:: + + ``PARTIAL SIGN`` is single-use per ``NONCE GEN`` call. The nonces are zeroed before the + function returns and the state goes back to IDLE, so a second ``PARTIAL SIGN`` against + the same ``aggR1, aggR2`` is rejected. To sign another message, run a full + ``NONCE GEN`` → ``LOAD COSIGNER KEY`` (× N) → ``PARTIAL SIGN`` cycle. + +**Status Words** + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - SW + - Description + * - ``0x9000`` + - Success + * - ``0x6700`` + - Data length is not 195 bytes + * - ``0x6985`` + - State is not WAIT_COSIGNERS, card's own key not in cosigner list, PIN/user-auth + not performed, or BIP-327 internal check failed (``R`` at infinity, ``a_i == 0``, + ``pk != secnonce[64:97]``, etc.) + * - ``0x6F00`` + - Internal computation failure (operands out of range); secrets are zeroed on this path + +---- + +References +---------- + +* BIP-327 — *MuSig2 for BIP340-compatible Multi-Signatures* +* BIP-340 — *Schnorr Signatures for secp256k1* +* BIP-341 — *Taproot: SegWit version 1 spending rules* (host-side tweak only) diff --git a/docs/source/commands_overview.rst b/docs/source/commands_overview.rst index 94f68a4..5566403 100644 --- a/docs/source/commands_overview.rst +++ b/docs/source/commands_overview.rst @@ -205,6 +205,34 @@ Signing & decryption - ✓ - ✗ +MuSig2 (BIP-327) +---------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 35 13 13 14 + + * - Command + - Description + - Secure Channel + - PIN / User Key + - PUK + * - Nonce Gen + - Generates MuSig2 secret nonce pair, returns ``(R1, R2)``. + - ✓ + - ✓ Except pinless + - ✗ + * - Load Cosigner Key + - Feeds one cosigner pubkey into the KeyAgg list hash. + - ✓ + - ✓ Except pinless + - ✗ + * - Partial Sign + - Returns the card's BIP-327 partial signature ``s_i``. + - ✓ + - ✓ Except pinless + - ✗ + Data & history -------------- @@ -260,5 +288,5 @@ Administration For detailed APDU specifications, parameters, and response codes of each command, refer to the dedicated command pages: - :doc:`commands_setup`, :doc:`commands_crypto`, :doc:`commands_data`, and - :doc:`commands_user_keys`. + :doc:`commands_setup`, :doc:`commands_crypto`, :doc:`commands_data`, + :doc:`commands_user_keys`, and :doc:`commands_musig2`. diff --git a/docs/source/commands_user_keys.rst b/docs/source/commands_user_keys.rst index 8e9afb4..cb4830b 100644 --- a/docs/source/commands_user_keys.rst +++ b/docs/source/commands_user_keys.rst @@ -309,6 +309,12 @@ This challenge must then be signed by the user and submitted with P1=0x02. - 32B - Random 256-bit nonce +.. note:: + + An active challenge survives an applet deselect. The host may disconnect the card + between the request (P1=1) and the response (P1=2), then re-open a Secure Channel + before submitting the response. + **P1=0x02 --- Challenge Response** The user submits the signed challenge to prove they hold the private key. The hash is not included @@ -330,6 +336,23 @@ in the data because the card uses the challenge it generated internally. On success (response = ``0x01``), this unlocks commands requiring PIN (except ``SIGN``). +.. note:: + + The challenge is strictly single-use. Each ``P1=0x02`` consumes the active nonce regardless + of whether the signature verifies (``response = 0x01``) or not (``response = 0x00``). Replaying + the same signature, or retrying a different one against the same nonce, is rejected with + ``0x6985``. A new challenge must be requested via P1=0x01. + +.. important:: + + While a challenge is active, only the following commands may be issued without + invalidating it: Get Manufacturer Certificate (``0xF7``), Get Card Certificate (``0xF8``), + Open Secure Channel (``0x10``), Mutually Authenticate (``0x11``), and the response itself + (this command with P1=0x02). These are the commands required to re-open a Secure Channel + after a deselect. Any other APDU (``VERIFY PIN``, ``DERIVE KEY``, ``SIGN``, …) resets the + auth FSM to IDLE before being processed, so a subsequent ``P1=0x02`` is rejected with + ``0x6985``. + **P1P2=0x0301 --- Read FIDO Credential ID** Returns the credential ID associated with the FIDO user key slot (slot 3), which the host needs @@ -436,8 +459,9 @@ The FIDO slot is limited to 3 hashes (instead of 4) when using the hash-list mod .. note:: A return value of ``0x00`` with status ``0x9000`` means the signature was not properly verified: - either the signature is invalid or the user key for this slot was not initialized. After a - failed challenge attempt, a new challenge must be requested (P1=1) before retrying (P1=2). + either the signature is invalid or the user key for this slot was not initialized. After any + ``P1=2`` attempt (failed or successful), a new challenge must be requested via ``P1=1`` before + the next ``P1=2``; the challenge is single-use. **Status Words** @@ -452,8 +476,8 @@ The FIDO slot is limited to 3 hashes (instead of 4) when using the hash-list mod * - ``0x6A80`` - Invalid slot index or incorrect data * - ``0x6985`` - - FIDO key not registered (CredID read), or P1=2 sent before P1=1, or challenge - expired (power cycle/deselect) + - FIDO key not registered (CredID read), ``P1=2`` sent without an active challenge + (no prior ``P1=1``, already consumed, or invalidated by a non-allowlisted APDU) ---- diff --git a/docs/source/index.rst b/docs/source/index.rst index 98d4a8e..8bb9507 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -38,6 +38,7 @@ This secures the dual generation process. commands_crypto commands_data commands_user_keys + commands_musig2 status_codes cryptographic_assets license diff --git a/docs/source/status_codes.rst b/docs/source/status_codes.rst index 8f2fbe8..7a7b6ec 100644 --- a/docs/source/status_codes.rst +++ b/docs/source/status_codes.rst @@ -67,7 +67,7 @@ PIN retry behavior - Behavior * - Per-session (transient) - 3 tries per power cycle. After 3 failures, returns ``0x63C0`` until power cycle. - * - Persistent (``OwnerPIN``) + * - Persistent - 6 total tries across sessions. Depleted = PIN blocked, requires PUK to unblock. .. seealso::