Files
hsm/signer_airgap_common.go
zeekay 6d2dc12ff8 hardware wallets + universal factory: Coldcard, Foundation, Keystone, NGRAVE, GridPlus, Ledger, Trezor
Add airgapped hardware wallet signers (Coldcard PSBT, Foundation BBQr,
Keystone UR, NGRAVE UR) wired through #106's AirgapTransport protocol.
Add online wallet signers shelling out to vendor CLIs (Ledger via
ledgerctl, Trezor via trezorctl) — no CGO/HID dependency. GridPlus
Lattice1 ships as a documented NotImpl with the full Connect-API
protocol spec; the Sign path returns a clear error rather than fake
output.

Extend factory.go to register the new providers plus the universal
PKCS#11 (Thales/Utimaco/Entrust/CloudHSM/SoftHSM2) and Nitrokey
HSM 2 paths from #106. Configuration is pulled from a shared config
map or matching MPC_HSM_<PROVIDER>_* environment variables.

Tests: 19 new signer + factory tests using MockAirgapTransport. All
pass under `go test ./...`. Real hardware tests are gated by build
tags (pkcs11, nitrokey, airgap_coldcard, …) and are not exercised in
CI.
2025-12-27 16:00:00 -08:00

61 lines
1.7 KiB
Go

// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package hsm
import (
"context"
"errors"
"fmt"
)
// airgappedSign is the common driver shared by every airgapped hardware
// wallet signer (Coldcard, Foundation Passport, Keystone Pro, NGRAVE
// Zero). It builds an AirgapChallenge using the per-device encoder,
// hands it to the configured AirgapTransport, and decodes the response.
//
// The function blocks for the full duration of the human ceremony. The
// caller's context is propagated to both DisplayChallenge and
// AwaitResponse so timeouts and cancellation work as expected.
func airgappedSign(
ctx context.Context,
transport AirgapTransport,
deviceID string,
encode func(message []byte, sessionID string) (AirgapChallenge, error),
decode func(response []byte) ([]byte, error),
message []byte,
) ([]byte, error) {
if transport == nil {
return nil, ErrAirgapTransportRequired
}
if len(message) == 0 {
return nil, errors.New("hsm/airgap: empty message")
}
sessionID, err := newSessionID()
if err != nil {
return nil, err
}
challenge, err := encode(message, sessionID)
if err != nil {
return nil, fmt.Errorf("hsm/airgap: encode challenge: %w", err)
}
challenge.DeviceID = deviceID
challenge.SessionID = sessionID
if err := transport.DisplayChallenge(ctx, challenge); err != nil {
return nil, fmt.Errorf("hsm/airgap: display: %w", err)
}
resp, err := transport.AwaitResponse(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("hsm/airgap: await: %w", err)
}
if len(resp) == 0 {
return nil, ErrAirgapResponseInvalid
}
sig, err := decode(resp)
if err != nil {
return nil, fmt.Errorf("hsm/airgap: decode response: %w", err)
}
return sig, nil
}