Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
5dc0d75
Initial plan
Copilot Feb 28, 2026
e950786
Update BIP85 GPG derivation paths to use single app number (828365) w…
Copilot Feb 28, 2026
ec0a710
Update test vectors and add tests for new curve types (P-384, P-521, …
Copilot Feb 28, 2026
fb45320
Remove unused BIP85_GPG_APP_RSA legacy alias per code review feedback
Copilot Feb 28, 2026
2d96c55
Add cross-implementation reference test vectors for all GPG key types
Copilot Mar 7, 2026
5407760
Fix XprvSeed BIP85 GPG key derivation: use seed.get_root() instead of…
Copilot Mar 7, 2026
fdfc220
Add seed type handling guidance to AGENTS.md
Copilot Mar 7, 2026
bb8a9ae
Fix sign_message double-derivation bug: accept root HDKey instead of …
Copilot Mar 7, 2026
d460ca4
Fix broken logger call in Satochip import error path
Copilot Mar 7, 2026
d864c7a
Add BIP85 GPG cross-implementation test vectors with bipsea, OpenSSL,…
Copilot Mar 8, 2026
5efb297
Add OpenSSL RSA cross-validation tests and BIP85 GPG cross-impl repor…
Copilot Mar 8, 2026
0d86df4
Validate BIP85 GPG against updated bipsea test vectors (d8f8d9075a)
Copilot Mar 8, 2026
65b6016
Address code review: add CRT coefficient comment, pgpy API note, fing…
Copilot Mar 8, 2026
c8fe0c5
Fix P-521 scalar derivation: use bit masking to match bipsea reference
Copilot Mar 8, 2026
3888e38
Use consistent bit masking for all ECC scalar derivation (not mod red…
Copilot Mar 8, 2026
1ae53eb
Add comment explaining BrainpoolP384r1 test vector exercises out-of-r…
Copilot Mar 8, 2026
f1b5b73
Fix Cv25519 ECDH subkey: clamp scalar and use little-endian MPI byte …
Copilot Mar 8, 2026
0af3eb6
Add tests: private key export for all ECC types and GPG round-trip
Copilot Mar 8, 2026
5634a2b
Add "View Keys" to GPG Tools menu with fingerprint and metadata display
Copilot Mar 8, 2026
b8fa607
Address code review: use consistent 16-char fingerprint suffix for no…
Copilot Mar 8, 2026
81b3323
Add Cv25519 implementation note to cross-impl report: no BIP85 spec c…
Copilot Mar 8, 2026
f541e12
Add pgpy dependency analysis and removal feasibility assessment to cr…
Copilot Mar 8, 2026
3b44221
Add python-gnupg migration analysis to BIP85_GPG_CROSS_IMPL_REPORT.md
Copilot Mar 8, 2026
df86f23
Replace all direct python-cryptography usage with PyCryptodome, embit…
Copilot Mar 8, 2026
fbedb7d
Add parameter comments to Brainpool curve tuples per code review
Copilot Mar 8, 2026
c11ff48
Switch to pycryptodome-based PGPy fork, remove python-cryptography de…
Copilot Mar 8, 2026
f2c1f55
Plan: switch back to pycryptodomex, use embit for secp256k1
Copilot Mar 8, 2026
1f71680
Remove all hash tags from requirements.txt, update pgpy comment for p…
Copilot Mar 8, 2026
484b902
Update PGPy fork URL to latest commit (5f6df6b5) with pycryptodomex b…
Copilot Mar 8, 2026
9e9ce84
Update PGPy fork URL to merged main branch commit (fa98d641)
Copilot Mar 8, 2026
a844327
Add secp256k1 and Brainpool ECDSA/ECDH signing support via ecdsa library
Copilot Mar 8, 2026
873e362
Address code review: fix type annotations and verify return value
Copilot Mar 8, 2026
1aa748c
Revert pgpy_ecdsa_patch.py: signing support should be in PGPy natively
Copilot Mar 8, 2026
e7130d6
Update PGPy to 756155da with native secp256k1/Brainpool support
Copilot Mar 8, 2026
de6c740
Add multi-backend crypto support with python-cryptography preferred, …
Copilot Mar 9, 2026
2766ee0
Improve test IDs for readability (small/large instead of numeric indi…
Copilot Mar 9, 2026
43c1227
Merge origin/dev into copilot/update-bip85-gpg-functionality
Copilot Mar 9, 2026
283527b
Update PGPy fork to 1c8d881f with dual backend support (cryptography …
Copilot Mar 9, 2026
414dfce
Fix locale-sensitive expiration date parsing and UnicodeError handling
Copilot Mar 9, 2026
665f6fd
Add tests for _normalize_date_input locale-robust date parsing
Copilot Mar 9, 2026
a417c53
Fix spelling: Normalising -> Normalizing in docstring
Copilot Mar 9, 2026
c486884
Fix Unicode .isdigit() locale issues and add locale guidance to CLAUD…
Copilot Mar 9, 2026
accd6ce
Address code review: improve test readability and fix comment accuracy
Copilot Mar 9, 2026
5b8b3e7
Add NFKD normalization to encrypted QR passwords, expand Unicode audi…
Copilot Mar 9, 2026
af5d929
Add pgpy and gnupg2 dependency checks to GPG menu
Copilot Mar 9, 2026
064bc87
Address code review: fix em-dash in comment, clean up test cleanup logic
Copilot Mar 9, 2026
62d5cc4
Refactor GPG View Keys: filter subkeys, add separate Key Details and …
Copilot Mar 9, 2026
d5671cb
Fix uid parsing clarity in ToolsGPGKeyDetailsView per code review
Copilot Mar 9, 2026
38a6bbc
Format fingerprint in 4-char blocks, fix subkey navigation to show su…
Copilot Mar 9, 2026
8997301
Remove BIP85 ECC curves setting; always enable ECC; show BIP85 warnin…
Copilot Mar 9, 2026
c65ebfb
Address code review: remove comment, clarify warning text
Copilot Mar 9, 2026
ce93bc6
Fix encrypt view error handling and add BIP85 key message CI tests
Copilot Mar 9, 2026
f0114fa
Address code review: improve error message, use TemporaryDirectory fo…
Copilot Mar 9, 2026
6d525e5
Add SETTING__GPG_KEY_TYPES multi-select setting to filter GPG key typ…
Copilot Mar 9, 2026
cfb3c5a
Add end-to-end GPG roundtrip tests for secure message sign/encrypt/de…
Copilot Mar 9, 2026
bd524ee
Address code review: improve error message and strengthen test assertion
Copilot Mar 9, 2026
2af3b8c
Fix Windows CI: convert GNUPGHOME path to MSYS2 format for GPG subpro…
Copilot Mar 9, 2026
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
58 changes: 58 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,68 @@ Because this project handles private key material for an air-gapped signer, **se
- Do not introduce new crypto dependencies or algorithms without explicit justification in the PR description.
- Keep reproducibility and deterministic builds in mind for security-sensitive changes.

### Seed type differences — `seed_bytes` vs `get_root()`

The codebase supports multiple seed types (see `src/seedsigner/models/seed.py`). They share a `Seed` base class but differ in critical ways. **Always use `seed.get_root(network)` to obtain the BIP-32 root key** — never call `bip32.HDKey.from_seed(seed.seed_bytes)` directly, because some seed types have `seed_bytes = None`.

| Type | `seed_bytes` | `get_root(network)` | Notes |
|------|-------------|---------------------|-------|
| **`Seed`** (BIP39) | 64-byte BIP39 seed (from mnemonic + passphrase) | Derives root from `seed_bytes` with network version | Standard path; passphrase changes `seed_bytes` |
| **`XprvSeed`** | **`None`** | Returns pre-parsed `_root` HDKey (network param ignored) | No mnemonic, no seed bytes — root key is the only secret |
| **`ElectrumSeed`** | PBKDF2-derived bytes | Inherited from `Seed` | Overrides `script_override`, `derivation_override`, `detect_version` |
| **`AezeedSeed`** | Aezeed entropy | Inherited from `Seed` | Decrypted from aezeed ciphertext |
| **`Slip39Seed`** | SLIP-39 master secret | Inherited from `Seed` | Recovered from share combination |

**Rules when writing code that handles seeds:**

- **Never access `seed.seed_bytes` directly for key derivation.** Call `seed.get_root(network)` instead. `XprvSeed.seed_bytes` is `None` and will crash `bip32.HDKey.from_seed()`.
- **Check for feature support before using seed-type-specific features.** For example, `seed.mnemonic_list` is empty for `XprvSeed`; `seed.seedqr_supported` is `False` for `XprvSeed`, `ElectrumSeed`, and `Slip39Seed`.
- **Respect method overrides.** `ElectrumSeed` overrides `derivation_override()`, `script_override`, and `detect_version()`. Always call these through the seed object rather than assuming BIP39 defaults.
- **When working with non-Seed-like objects** (e.g. in helper functions that may receive mock objects or raw HDKeys), use the pattern `if hasattr(seed, "get_root"): root = seed.get_root()` with a fallback to `bip32.HDKey.from_seed(seed.seed_bytes)`.
- **Test with multiple seed types.** Any new feature touching key derivation, BIP85, address verification, or PSBT signing should be tested with at least `Seed` (BIP39) and `XprvSeed` to catch `seed_bytes = None` issues.

### Code review expectations for sensitive changes
For changes touching entropy, seed generation/import, key derivation, signing, or secret storage:
- Add/extend tests for both success and failure/cleanup paths.
- For seed creation/loading features, test all supported workflows for consistent behavior and fault tolerance.
- Prefer shared code paths across workflows (scan/manual/import) instead of duplicating seed-handling logic.
- Document threat assumptions and failure modes in code comments or PR notes.
- Call out any remaining risk tradeoffs explicitly.

## Unicode and locale-safe string handling

SeedSigner must produce identical results regardless of the host locale or input method. Follow these rules when processing user-supplied or externally-sourced strings:

### Normalization
- **BIP39 / SLIP39 / Electrum passphrases and mnemonics** must be NFKD-normalized (already done in `seed.py`). Do not change the normalization form.
- **Encrypted QR code passwords** (`kef.py` `Cipher` class) are NFKD-normalized before PBKDF2 key derivation, ensuring the same password produces the same encryption key regardless of platform (macOS typically stores NFD, Linux/Windows use NFC).
- **Display strings** shown to the user should be NFC-normalized (also already done in `seed.py` via `passphrase_display` / `mnemonic_display_str`).

### Normalization audit summary

The following table lists every code path where user-supplied strings feed into key derivation, encryption, or deterministic output, and whether NFKD normalization is applied:

| Code path | Normalized? | Where |
|-----------|-------------|-------|
| BIP-39 passphrase | ✅ NFKD | `Seed.set_passphrase()` in `seed.py` |
| BIP-39 mnemonic | ✅ NFKD | `Seed.__init__()` in `seed.py` |
| SLIP-39 passphrase | ✅ NFKD | `Slip39Seed.set_slip39_passphrase()` in `seed.py` |
| Electrum passphrase | ✅ NFKD + lower() | `ElectrumSeed.normalize_electrum_passphrase()` in `seed.py` |
| Aezeed passphrase | ✅ NFKD (inherited) | Via `Seed.set_passphrase()` before reaching `aezeed.decode_mnemonic()` |
| Encrypted QR password | ✅ NFKD | `Cipher.__init__()` in `kef.py` |
| Dice / coin-flip entropy | N/A (ASCII-only) | Input constrained to `1-6` / `0-1` by keyboard UI |
| GPG name / email | N/A (ASCII keyboard) | Input constrained to ASCII by on-screen keyboard |
| GPG expiration dates | ✅ dash-normalized | `_normalize_date_input()` in `tools_views.py` |

When adding a **new** code path that derives keys or produces deterministic output from user-supplied strings, always NFKD-normalize the input before encoding to bytes.

### Date and numeric input
- When parsing dates from user input, always use `_normalize_date_input()` (in `tools_views.py`) to replace non-ASCII dashes (fullwidth `\uff0d`, en-dash `\u2013`, em-dash `\u2014`, Unicode minus `\u2212`) with ASCII hyphen-minus before calling `strptime` / `fromisoformat`.
- When converting user-provided numeric strings use `try/except ValueError` around `int()` / `float()` instead of pre-checking with `.isdigit()`. Python's `.isdigit()` returns `True` for non-ASCII Unicode digit characters (e.g. superscript `¹²³`) that `int()` / `float()` cannot convert, so the pre-check gives a false positive and the subsequent conversion raises `ValueError`.
- If an ASCII-only digit check is truly needed, combine `.isascii()` and `.isdigit()`, or test membership in `"0123456789"`.

### General rules
- Never rely on locale-dependent behaviour (`str.lower()` with Turkish İ, `strftime` with locale month names, etc.) for data that affects derivation, signing, or deterministic output.
- QR-scanned data, settings QRs, and file-imported data should all be treated as untrusted byte strings; decode as UTF-8 with error handling before further processing.
- When adding new user-input parsing, add tests that exercise at least one non-ASCII variant (e.g. a fullwidth digit, a non-ASCII dash) to catch locale-dependent regressions.
- Be aware that the same Unicode character can have multiple representations (e.g. `é` can be U+00E9 [NFC] or U+0065 U+0301 [NFD]). macOS file-system APIs and some input methods produce NFD; most other systems produce NFC. NFKD normalization collapses both forms into a single canonical byte sequence.
7 changes: 4 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
embit==0.8.0
Pillow==10.4.0
pyzbar @ https://github.com/seedsigner/pyzbar/archive/c3c237821c6a20b17953efe59b90df0b514a1c03.zip#sha256=74c8330077013d17fb56d9677292dd0b854e44659111f26e210061bda53e72e0
pyzbar @ https://github.com/seedsigner/pyzbar/archive/c3c237821c6a20b17953efe59b90df0b514a1c03.zip
qrcode==7.3.1
colorama==0.4.6 ; platform_system == "Windows"
urtypes @ https://github.com/selfcustody/urtypes/archive/7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a.zip#sha256=a44046378f6f1bca21cee40ce941d7509e1ec1e8677b5b3b146a918fa6fd475d
urtypes @ https://github.com/selfcustody/urtypes/archive/7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a.zip
pycryptodomex==3.23.0
pgpy==0.6.0
pgpy @ https://github.com/3rdIteration/PGPy/archive/1c8d881f84c455472114e5acf1ccdbc8809dd72f.zip # v0.6.0 fork: cryptography primary backend, pycryptodomex/ecdsa/embit fallbacks
pyasn1==0.6.2
pysatochip==0.17.0
pyscard==2.3.1
Expand All @@ -18,4 +18,5 @@ certifi==2025.7.14
six==1.17.0
cffi==1.17.1
pycparser==2.22
smbus2==0.4.3 ; platform_system != "Windows"
shamir-mnemonic==0.3.0
286 changes: 286 additions & 0 deletions src/seedsigner/helpers/ec_point.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
"""EC public-key derivation with multiple crypto backend support.

Supports automatic fail-over between backends:

* **python-cryptography** — preferred when available (C-accelerated)
* **PyCryptodome** (pycryptodomex) — NIST curves, Ed25519, Curve25519
* **embit** — secp256k1 (native Bitcoin library)
* **ecdsa** — pure-Python ECDSA for NIST, secp256k1, and Brainpool
* **pure_python** — minimal double-and-add for Brainpool curves

The module auto-detects which libraries are installed and uses the best
available backend. Use :func:`set_backend` to force a specific backend
for testing or compatibility.
"""
from __future__ import annotations


# ---------------------------------------------------------------------------
# Backend name constants
# ---------------------------------------------------------------------------

CRYPTOGRAPHY = "cryptography"
PYCRYPTODOME = "pycryptodome"
EMBIT = "embit"
ECDSA_LIB = "ecdsa"
PURE_PYTHON = "pure_python"


# ---------------------------------------------------------------------------
# Backend detection
# ---------------------------------------------------------------------------

def _detect_backends() -> set[str]:
"""Detect which crypto backends are importable."""
found: set[str] = {PURE_PYTHON}
try:
import ecdsa as _ecdsa # noqa: F841
found.add(ECDSA_LIB)
except ImportError:
pass
try:
from embit import ec as _ec # noqa: F841
found.add(EMBIT)
except ImportError:
pass
try:
from Cryptodome.PublicKey import ECC as _ECC # noqa: F841
found.add(PYCRYPTODOME)
except ImportError:
pass
try:
from cryptography.hazmat.primitives.asymmetric import ec as _cry # noqa: F841
found.add(CRYPTOGRAPHY)
except ImportError:
pass
return found


_available: set[str] = _detect_backends()
_forced_backend: str | None = None


def available_backends() -> frozenset[str]:
"""Return the set of detected crypto backend names."""
return frozenset(_available)


def set_backend(name: str | None) -> None:
"""Force a specific backend, or ``None`` for auto-detection.

Raises :exc:`ValueError` if *name* is not installed.
"""
global _forced_backend
if name is not None and name not in _available:
raise ValueError(
f"Backend {name!r} is not available. "
f"Installed: {sorted(_available)}"
)
_forced_backend = name


def get_backend() -> str | None:
"""Return the forced backend name, or ``None`` if auto-detecting."""
return _forced_backend


# ---------------------------------------------------------------------------
# Helper: check if a backend should be tried
# ---------------------------------------------------------------------------

def _should_try(name: str) -> bool:
"""Return True if *name* should be attempted given current settings."""
return name in _available and (_forced_backend is None or _forced_backend == name)


# ---------------------------------------------------------------------------
# Pure-Python EC point multiplication (for Brainpool curves)
# ---------------------------------------------------------------------------

def _ec_point_mul(p: int, a: int, Gx: int, Gy: int, d: int) -> tuple[int, int]:
"""Compute ``d * G`` on the curve ``y² = x³ + ax + b (mod p)``.

Uses the standard double-and-add algorithm. The ``b`` coefficient is
not needed for point operations — only ``p`` (field prime) and ``a``
are required.

Returns the affine coordinates ``(x, y)`` of the result.
"""

def _add(px: int, py: int, qx: int, qy: int) -> tuple[int, int]:
# Identity element represented as (0, 0).
if px == 0 and py == 0:
return qx, qy
if qx == 0 and qy == 0:
return px, py
if px == qx:
if py == qy and py != 0:
lam = (3 * px * px + a) * pow(2 * py, -1, p) % p
else:
return 0, 0
else:
lam = (qy - py) * pow(qx - px, -1, p) % p
rx = (lam * lam - px - qx) % p
ry = (lam * (px - rx) - py) % p
return rx, ry

rx, ry = 0, 0
tx, ty = Gx, Gy
while d > 0:
if d & 1:
rx, ry = _add(rx, ry, tx, ty)
tx, ty = _add(tx, ty, tx, ty)
d >>= 1
return rx, ry


# ---------------------------------------------------------------------------
# Brainpool curve parameters (field prime, coefficient a, generator point)
# from RFC 5639 — verified against OpenSSL ``ecparam -param_enc explicit``.
# ---------------------------------------------------------------------------

_BRAINPOOL_P256R1 = ( # (p, a, Gx, Gy)
0xA9FB57DBA1EEA9BC3E660A909D838D726E3BF623D52620282013481D1F6E5377,
0x7D5A0975FC2C3057EEF67530417AFFE7FB8055C126DC5C6CE94A4B44F330B5D9,
0x8BD2AEB9CB7E57CB2C4B482FFC81B7AFB9DE27E1E3BD23C23A4453BD9ACE3262,
0x547EF835C3DAC4FD97F8461A14611DC9C27745132DED8E545C1D54C72F046997,
)

_BRAINPOOL_P384R1 = ( # (p, a, Gx, Gy)
0x8CB91E82A3386D280F5D6F7E50E641DF152F7109ED5456B412B1DA197FB71123ACD3A729901D1A71874700133107EC53,
0x7BC382C63D8C150C3C72080ACE05AFA0C2BEA28E4FB22787139165EFBA91F90F8AA5814A503AD4EB04A8C7DD22CE2826,
0x1D1C64F068CF45FFA2A63A81B7C13F6B8847A3E77EF14FE3DB7FCAFE0CBD10E8E826E03436D646AAEF87B2E247D4AF1E,
0x8ABE1D7520F9C2A45CB1EB8E95CFD55262B70B29FEEC5864E19C054FF99129280E4646217791811142820341263C5315,
)

_BRAINPOOL_P512R1 = ( # (p, a, Gx, Gy)
0xAADD9DB8DBE9C48B3FD4E6AE33C9FC07CB308DB3B3C9D20ED6639CCA703308717D4D9B009BC66842AECDA12AE6A380E62881FF2F2D82C68528AA6056583A48F3,
0x7830A3318B603B89E2327145AC234CC594CBDD8D3DF91610A83441CAEA9863BC2DED5D5AA8253AA10A2EF1C98B9AC8B57F1117A72BF2C7B9E7C1AC4D77FC94CA,
0x81AEE4BDD82ED9645A21322E9C4C6A9385ED9F70B5D916C1B43B62EEF4D0098EFF3B1F78E2D0D48D50D1687B93B97D5F7C6D5047406A5E688B352209BCB9F822,
0x7DDE385D566332ECC0EABFA9CF7822FDF209F70024A57B1AA000C55B881F8111B2DCDE494A5F485E5BCA4BD88A2763AED1CA2B2FA8F0540678CD1E0F3AD80892,
)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def ed25519_pub_from_seed(seed: bytes) -> bytes:
"""Derive the 32-byte Ed25519 public key from a 32-byte seed."""
if _should_try(CRYPTOGRAPHY):
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
return Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes(
Encoding.Raw, PublicFormat.Raw)

if _should_try(PYCRYPTODOME):
from Cryptodome.PublicKey import ECC
return ECC.construct(curve="Ed25519", seed=seed).public_key().export_key(format="raw")

raise ImportError(
f"No backend for Ed25519 (forced={_forced_backend}, available={sorted(_available)})")


def curve25519_pub_from_seed(seed: bytes) -> bytes:
"""Derive the 32-byte Curve25519 (X25519) public key from a 32-byte seed."""
if _should_try(CRYPTOGRAPHY):
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
return X25519PrivateKey.from_private_bytes(seed).public_key().public_bytes(
Encoding.Raw, PublicFormat.Raw)

if _should_try(PYCRYPTODOME):
from Cryptodome.PublicKey import ECC
return ECC.construct(curve="Curve25519", seed=seed).public_key().export_key(format="raw")

raise ImportError(
f"No backend for Curve25519 (forced={_forced_backend}, available={sorted(_available)})")


def secp256k1_pub_xy(d: int) -> tuple[int, int]:
"""Derive the secp256k1 public key ``(x, y)`` from private scalar *d*."""
if _should_try(CRYPTOGRAPHY):
from cryptography.hazmat.primitives.asymmetric import ec
key = ec.derive_private_key(d, ec.SECP256K1())
nums = key.public_key().public_numbers()
return nums.x, nums.y

if _should_try(EMBIT):
from embit import ec as embit_ec
priv = embit_ec.PrivateKey(d.to_bytes(32, "big"))
pub = priv.get_public_key()
pub.compressed = False
raw = pub.sec() # 0x04 || x (32 bytes) || y (32 bytes)
return int.from_bytes(raw[1:33], "big"), int.from_bytes(raw[33:65], "big")

if _should_try(ECDSA_LIB):
import ecdsa
sk = ecdsa.SigningKey.from_secret_exponent(d, curve=ecdsa.SECP256k1)
raw = sk.get_verifying_key().to_string()
sz = len(raw) // 2
return int.from_bytes(raw[:sz], "big"), int.from_bytes(raw[sz:], "big")

raise ImportError(
f"No backend for secp256k1 (forced={_forced_backend}, available={sorted(_available)})")


def nist_pub_xy(curve_name: str, d: int) -> tuple[int, int]:
"""Derive NIST public key ``(x, y)`` from private scalar *d*.

*curve_name* must be one of ``"P-256"``, ``"P-384"``, or ``"P-521"``.
"""
if _should_try(CRYPTOGRAPHY):
from cryptography.hazmat.primitives.asymmetric import ec
_CURVES = {"P-256": ec.SECP256R1(), "P-384": ec.SECP384R1(), "P-521": ec.SECP521R1()}
key = ec.derive_private_key(d, _CURVES[curve_name])
nums = key.public_key().public_numbers()
return nums.x, nums.y

if _should_try(PYCRYPTODOME):
from Cryptodome.PublicKey import ECC
key = ECC.construct(curve=curve_name, d=d)
return int(key.pointQ.x), int(key.pointQ.y)

if _should_try(ECDSA_LIB):
import ecdsa
_CURVES = {"P-256": ecdsa.NIST256p, "P-384": ecdsa.NIST384p, "P-521": ecdsa.NIST521p}
sk = ecdsa.SigningKey.from_secret_exponent(d, curve=_CURVES[curve_name])
raw = sk.get_verifying_key().to_string()
sz = len(raw) // 2
return int.from_bytes(raw[:sz], "big"), int.from_bytes(raw[sz:], "big")

raise ImportError(
f"No backend for {curve_name} (forced={_forced_backend}, available={sorted(_available)})")


def brainpool_pub_xy(bits: int, d: int) -> tuple[int, int]:
"""Derive a Brainpool public key ``(x, y)`` from private scalar *d*.

*bits* must be ``256``, ``384``, or ``512``.
"""
if _should_try(CRYPTOGRAPHY):
from cryptography.hazmat.primitives.asymmetric import ec
_CURVES = {256: ec.BrainpoolP256R1(), 384: ec.BrainpoolP384R1(), 512: ec.BrainpoolP512R1()}
key = ec.derive_private_key(d, _CURVES[bits])
nums = key.public_key().public_numbers()
return nums.x, nums.y

if _should_try(ECDSA_LIB):
import ecdsa
_CURVES = {256: ecdsa.BRAINPOOLP256r1, 384: ecdsa.BRAINPOOLP384r1, 512: ecdsa.BRAINPOOLP512r1}
sk = ecdsa.SigningKey.from_secret_exponent(d, curve=_CURVES[bits])
raw = sk.get_verifying_key().to_string()
sz = len(raw) // 2
return int.from_bytes(raw[:sz], "big"), int.from_bytes(raw[sz:], "big")

if _should_try(PURE_PYTHON):
params = {
256: _BRAINPOOL_P256R1,
384: _BRAINPOOL_P384R1,
512: _BRAINPOOL_P512R1,
}
p, a, Gx, Gy = params[bits]
return _ec_point_mul(p, a, Gx, Gy, d)

raise ImportError(
f"No backend for Brainpool P-{bits} (forced={_forced_backend}, available={sorted(_available)})")
Loading