Skip to content
Open
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
138 changes: 138 additions & 0 deletions docs/bip85_gpg_version_history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# BIP85 GPG Version History

SeedSigner implements [BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki)
for deterministic GPG key derivation. The derivation scheme has evolved through
several versions as the spec and available hardware matured.

## Overview

| Version | Period | Tags | Key change |
|---------|--------|------|------------|
| v0 | Aug 31 – Sep 14, 2025 | *(development only, no release)* | Initial implementation; all keys use app 828365 |
| v1 | Sep 15, 2025 – Mar 8, 2026 | `SS0.8.6+Satochip+Earthdiver-B4` … `SeSi-0.8.6+ShSi-B8` | Separate BIP85 app per curve |
| v2 | Mar 9, 2026 – Jun 2026 | `SeSi-0.8.6+ShSi-B9`, `SeSi-0.8.6+ShSi-B10` | Unified app 828365 with `key_type` codes |
| v3 | Jun 2026+ (planned) | *(B11-TestingFixes branch, unreleased)* | Split RSA (828365) and ECC (828366) apps |

## Detailed per-version description

### v0 (development — unreleased)

First working implementation. Every curve used the same BIP85 app number and
a simple `[param, index]` path.

- **App**: `828365'` for all key types
- **Path format**: `m/83696968'/828365'/{param}'/{index}'`
- **Param**: `{key_bits}` for RSA (e.g. 2048), `259` for Curve25519, `256` for ECDSA curves
- **Curves**: RSA 2048/3072/4096, Curve25519, secp256k1, NIST P-256
- **Tags**: None — development-only. The compatible upstream
[bipsea](https://github.com/3rdIteration/bipsea) test vectors were never
generated for this version.

### v1 (tagged releases B4 through B8)

Each curve got its own BIP85 app number. ECC was restricted to 256-bit.

- **Apps**:

| Curve | App |
|-------|-----|
| RSA | `828365'` |
| Curve25519 | `828366'` |
| secp256k1 | `828367'` |
| NIST P-256 | `828368'` |
| Brainpool P-256 | `828369'` |

- **Path format**: `m/83696968'/{app}'/256'/{index}'` (ECC); `m/83696968'/828365'/{bits}'/{index}'` (RSA)
- **Key type codes**: Not used — the curve was encoded in the app number
- **Curves**: RSA, Curve25519, secp256k1, NIST P-256, Brainpool P-256
- **Tags**: `SS0.8.6+Satochip+Earthdiver-B4` through `SeSi-0.8.6+ShSi-B8`

### v2 (tagged releases B9, B10)

Unified all curves under a single app number with a `key_type` discriminator.
Added P-384, P-521, Brainpool P-384, Brainpool P-512.

- **App**: `828365'` for all key types
- **Path format**: `m/83696968'/828365'/{key_type}'/{key_bits}'/{index}'[/{sub_index}']`
- **Key type codes**:

| Code | Curve |
|------|-------|
| 0 | RSA |
| 1 | Curve25519 |
| 2 | secp256k1 |
| 3 | NIST P-256 / P-384 / P-521 |
| 4 | Brainpool P-256 / P-384 / P-512 |

- **Curves**: RSA, Curve25519, secp256k1, NIST (P-256/P-384/P-521), Brainpool (P-256/P-384/P-512)
- **Tags**: `SeSi-0.8.6+ShSi-B9`, `SeSi-0.8.6+ShSi-B10`

### v3 (B11-TestingFixes branch — unreleased)

**Breaking change for ECC keys only.** RSA key derivation is identical to v2.

Splits RSA and ECC into separate apps and remaps ECC `key_type` codes so the
least-used curve (Brainpool) occupies code 0, reducing migration friction.

- **Apps**:

| Family | App |
|--------|-----|
| RSA | `828365'` (unchanged from v2) |
| ECC | `828366'` (new) |

- **Path format**:
- RSA: `m/83696968'/828365'/0'/{bits}'/{index}'[/{sub_index}']`
- ECC: `m/83696968'/828366'/{key_type}'/{key_bits}'/{index}'[/{sub_index}']`
- **ECC key type codes** (remapped):

| Code | Curve |
|------|-------|
| 0 | Brainpool P-256 / P-384 / P-512 |
| 1 | Curve25519 |
| 2 | secp256k1 |
| 3 | NIST P-256 / P-384 / P-521 |

- **RSA key type code**: 0 (unchanged from v2)
- **Curves**: Same as v2

## Summary table

```
v0 (dev) v1 (B4-B8) v2 (B9-B10) v3 (B11)
────────────────────────────────────────────────────────────────
App 828365' 828365'-828369' 828365' 828365' (RSA)
828366' (ECC)

Path [param, idx] [256, idx] (ECC) [kt, bits, idx] [kt, bits, idx]
[bits, idx] (RSA)

ECC kt N/A N/A 1=C25519 1=C25519
2=secp256k1 2=secp256k1
3=NIST 3=NIST
4=Brainpool 0=Brainpool

RSA kt N/A N/A 0 0

P-384/ ✗ ✗ ✓ ✓
P-521

Brainpool ✗ P-256 only ✓ ✓
```

## Notes

- **v0 was never released** — no tagged release or public build contains it.
The compatible bipsea test vectors were generated starting from v1.
- **v2 → v3 is a breaking change for ECC keys** — the app number changed
from `828365'` to `828366'` and key_type codes were remapped. RSA keys
are unaffected.
- **Upstream SeedSigner (v0.8.7)** does **not** contain any GPG support.
All versions described above are on the
[3rdIteration](https://github.com/3rdIteration/SeedSigner) fork.

## See also

- [`docs/gpg_tools.md`](gpg_tools.md) — user-facing GPG feature documentation
- [`tools/bip85_pgp.py`](../tools/bip85_pgp.py) — standalone CLI tool
- [bipsea test vectors](https://github.com/3rdIteration/bipsea/blob/main/test_vectors.md)
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ qrcode==7.3.1
colorama==0.4.6 ; platform_system == "Windows"
urtypes @ https://github.com/selfcustody/urtypes/archive/7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a.zip
pycryptodomex==3.23.0
pgpy @ https://github.com/3rdIteration/PGPy/archive/1c8d881f84c455472114e5acf1ccdbc8809dd72f.zip # v0.6.0 fork: cryptography primary backend, pycryptodomex/ecdsa/embit fallbacks
pgpy @ https://github.com/3rdIteration/PGPy/archive/7cdad000a76ced53c873211241d5ba20019a8488.zip # v0.6.0 fork: cryptography primary backend, pycryptodomex/ecdsa/embit fallbacks
pyasn1==0.6.2
pygp @ https://github.com/3rdIteration/pygp/archive/15682ec8fd042b5d0ae3422e9434e9734db6e55b.zip
pysatochip==0.17.0
Expand Down
29 changes: 26 additions & 3 deletions src/seedsigner/gui/screens/seed_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,9 +910,32 @@ def _render(self):

def _run(self):
cursor_position = len(self.passphrase)
cur_keyboard = self.keyboard_abc
cur_button1_text = self.KEYBOARD__UPPERCASE_BUTTON_TEXT
cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT

# Initialize keyboard state to match initial_keyboard (same logic as _render)
if self.initial_keyboard == self.KEYBOARD__UPPERCASE_BUTTON_TEXT:
cur_keyboard = self.keyboard_ABC
cur_button1_text = self.KEYBOARD__LOWERCASE_BUTTON_TEXT
cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT

elif self.initial_keyboard == self.KEYBOARD__DIGITS_BUTTON_TEXT:
cur_keyboard = self.keyboard_digits
cur_button1_text = self.KEYBOARD__UPPERCASE_BUTTON_TEXT
cur_button2_text = self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT

elif self.initial_keyboard == self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT:
cur_keyboard = self.keyboard_symbols_1
cur_button1_text = self.KEYBOARD__UPPERCASE_BUTTON_TEXT
cur_button2_text = self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT

elif self.initial_keyboard == self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT:
cur_keyboard = self.keyboard_symbols_2
cur_button1_text = self.KEYBOARD__UPPERCASE_BUTTON_TEXT
cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT

else:
cur_keyboard = self.keyboard_abc
cur_button1_text = self.KEYBOARD__UPPERCASE_BUTTON_TEXT
cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT

# Start the interactive update loop
while True:
Expand Down
131 changes: 129 additions & 2 deletions src/seedsigner/helpers/keycard_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,123 @@ def get_keycard_class():
return None


def _patch_keycard_sign():
"""Monkey-patch keycard-py's sign() to tolerate non-canonical DER.

The ``ecdsa`` library's ``sigdecode_der`` strictly rejects non-minimal
integer encodings (extra leading zero padding bytes) on some platforms,
causing Keycard signing to fail with::

UnexpectedDER: Invalid encoding of integer, unnecessary zero padding bytes

This patch replaces the strict ``sigdecode_der`` call in keycard-py's
``commands.sign`` module with a tolerant parser that strips leading zeros.
"""
try:
from keycard.commands import sign as _sign_module
except Exception:
return # Already patched or unavailable

# Skip if already patched
if hasattr(_sign_module, "_parse_der_sig"):
return

def _parse_der_int(data):
"""Parse a DER INTEGER, tolerating non-minimal leading zero padding."""
if len(data) < 2 or data[0] != 0x02:
raise ValueError("Expected DER INTEGER tag")
length = data[1]
if len(data) < 2 + length:
raise ValueError("DER INTEGER truncated")
value_bytes = data[2 : 2 + length]
value = int.from_bytes(value_bytes, "big", signed=False)
return value, 2 + length

def _parse_der_sig(der):
"""Parse a DER ECDSA signature into (r, s), tolerating non-minimal encoding."""
if len(der) < 4 or der[0] != 0x30:
raise ValueError("Expected DER SEQUENCE tag")
body = der[2:]
r, consumed = _parse_der_int(body)
s, _consumed2 = _parse_der_int(body[consumed:])
return r, s

# Inject helpers into the module namespace so sign() can use them.
_sign_module._parse_der_sig = _parse_der_sig # noqa: SLF001

# Replace the original sign function with a patched version that uses
# our tolerant DER parser instead of ecdsa's strict sigdecode_der.
import types as _types

def _patched_sign(card, digest, p1=None, p2=None, derivation_path=None):
from keycard import constants as _constants
from keycard.constants import (
DerivationOption,
DerivationSource,
SigningAlgorithm,
)
from keycard.exceptions import InvalidStateError
from keycard.parsing import tlv as _tlv
from keycard.parsing.keypath import KeyPath
from keycard.parsing.signature_result import SignatureResult

if p2 != SigningAlgorithm.ECDSA_SECP256K1:
raise NotImplementedError("Signature algorithm not supported")
if len(digest) != 32:
raise ValueError("Digest must be exactly 32 bytes")
if p1 != DerivationOption.PINLESS and not card.is_pin_verified:
raise InvalidStateError(
"PIN must be verified to sign with this derivation option"
)

data = digest
source = DerivationSource.MASTER
if p1 in (DerivationOption.DERIVE, DerivationOption.DERIVE_AND_MAKE_CURRENT):
if not derivation_path:
raise ValueError("Derivation path cannot be empty")
key_path = KeyPath(derivation_path)
data += key_path.data
source = key_path.source

response = card.send_secure_apdu(
ins=_constants.INS_SIGN, p1=p1 | source, p2=p2, data=data
)

if response.startswith(b"\xa0"):
outer = _tlv.parse_tlv(response)
inner = _tlv.parse_tlv(outer[0xA0][0])
der_bytes = (
b"\x30"
+ len(inner[0x30][0]).to_bytes(1, "big")
+ inner[0x30][0]
)
r, s = _parse_der_sig(der_bytes)
pub = inner.get(0x80, [None])[0]
return SignatureResult(
algo=p2, digest=digest, r=r, s=s, public_key=pub
)
elif response.startswith(b"\x80"):
outer = _tlv.parse_tlv(response)
raw = outer[0x80][0]
if len(raw) != 65:
raise ValueError("Expected 65-byte raw signature (r||s||recId)")
return SignatureResult(
algo=p2,
digest=digest,
r=int.from_bytes(raw[:32], "big"),
s=int.from_bytes(raw[32:64], "big"),
recovery_id=int(raw[64]),
)

raise ValueError("Unexpected SIGN response format")

_sign_module.sign = _patched_sign # noqa: SLF001


# Apply the monkey-patch at import time so all downstream callers benefit.
_patch_keycard_sign()


def _pin_to_text(pin) -> str:
if isinstance(pin, str):
return pin
Expand Down Expand Up @@ -775,7 +892,12 @@ def card_sign_transaction_hash(self, keynbr, txhash, chalresponse=None):
else:
sig = self._card.sign(digest)

der = sig.signature_der
der = bytes(sig.signature_der)
try:
from seedsigner.helpers.satochip_signer import normalize_signature_der
der = normalize_signature_der(der)
except Exception:
pass
return (list(der), 0x90, 0x00)

def card_sign_message(self, keynbr, pubkey, message, hmac=b"", altcoin=None):
Expand All @@ -790,7 +912,12 @@ def card_sign_message(self, keynbr, pubkey, message, hmac=b"", altcoin=None):
else:
sig = self._card.sign(digest)

der = sig.signature_der
der = bytes(sig.signature_der)
try:
from seedsigner.helpers.satochip_signer import normalize_signature_der
der = normalize_signature_der(der)
except Exception:
pass
compact = getattr(sig, "signature", None)
if not compact or len(compact) != 65:
compact = b"\x1f" + b"\x00" * 64
Expand Down
14 changes: 11 additions & 3 deletions src/seedsigner/helpers/keycard_signer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from seedsigner.helpers.iso7816 import format_sw_error
from seedsigner.helpers.satochip_signer import (
SignResult,
_call_with_timeout,
_format_path,
normalize_signature_der,
Expand All @@ -19,25 +20,31 @@
logger = logging.getLogger(__name__)


def sign_psbt_with_keycard(psbt: PSBT, connector) -> int:
def sign_psbt_with_keycard(psbt: PSBT, connector, timeout: float | None = None) -> SignResult:
"""Sign PSBT inputs with a Keycard backend.

Keycard shells may reject pubkey export for arbitrary child paths with
SW=6982. For single-derivation inputs, this signer falls back to path-based
signing by setting the connector's current derivation path directly.

If ``timeout`` is passed it overrides the configured setting value.

Returns a SignResult with signed_count and timed_out flag.
"""

settings = Settings.get_instance()
# Keycard operations (derive_key + sign via sign_with_path) are significantly
# slower than native Satochip signing (~1.0s per card_sign_transaction_hash),
# so they use a dedicated, higher default timeout (see SETTING__KEYCARD_SIGN_TIMEOUT).
timeout = settings.get_value(SettingsConstants.SETTING__KEYCARD_SIGN_TIMEOUT)
if timeout is None:
timeout = settings.get_value(SettingsConstants.SETTING__KEYCARD_SIGN_TIMEOUT)
pre_dummy_max = settings.get_value(SettingsConstants.SETTING__SATOCHIP_MAX_PRE_DUMMIES)
post_dummy_max = settings.get_value(SettingsConstants.SETTING__SATOCHIP_MAX_POST_DUMMIES)
in_tx_dummy_max = settings.get_value(SettingsConstants.SETTING__SATOCHIP_MAX_IN_TX_DUMMIES)
dummy_prob = settings.get_value(SettingsConstants.SETTING__SATOCHIP_DUMMY_PROBABILITY) / 100

signed = 0
timed_out = False

pre_dummy_count = random.randint(0, pre_dummy_max)
logger.info("Pre-signing dummy signatures: %d", pre_dummy_count)
Expand Down Expand Up @@ -125,6 +132,7 @@ def sign_psbt_with_keycard(psbt: PSBT, connector) -> int:
)
except TimeoutError:
logger.warning("Keycard signing timed out")
timed_out = True
results.append(None)
except Exception:
results.append(None)
Expand Down Expand Up @@ -175,4 +183,4 @@ def sign_psbt_with_keycard(psbt: PSBT, connector) -> int:
except Exception:
pass

return signed
return SignResult(signed_count=signed, timed_out=timed_out)
Loading
Loading