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
109 changes: 108 additions & 1 deletion cmd/signer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package main

import (
"bufio"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"flag"
Expand Down Expand Up @@ -39,6 +41,10 @@ func run(args []string, stdin *os.File, stdout, stderr io.Writer) error {
switch args[0] {
case "sign-send":
return signSend(args[1:], stdin, stdout, stderr)
case "prepare-send":
return prepareSend(args[1:], stdout, stderr)
case "finalize-send":
return finalizeSend(args[1:], stdout, stderr)
case "inspect":
return inspect(args[1:], stdout)
case "help", "-h", "--help":
Expand All @@ -52,13 +58,114 @@ func run(args []string, stdin *os.File, stdout, stderr io.Writer) error {
func usage(w io.Writer) error {
_, _ = fmt.Fprintln(w, `Usage:
signer sign-send --keystore-dir DIR --key ADDRESS_OR_NICKNAME REQUEST.json
signer prepare-send --public-key HEX [--payload-file FILE] REQUEST.json
signer finalize-send --signature-file FILE [--signature-format raw|der] [--signature-encoding raw|hex|base64] PREPARED.json
signer inspect SIGNED.json

sign-send reads the keystore password from a terminal without echo, or from
standard input when piped. The signed /v1/tx JSON is written to standard output.`)
standard input when piped. prepare-send and finalize-send keep private keys in
an external HSM. Signed /v1/tx JSON is written to standard output.`)
return nil
}

// prepareSend constructs a canonical native send and writes a portable HSM request.
func prepareSend(args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("prepare-send", flag.ContinueOnError)
flags.SetOutput(stderr)
publicKeyHex := flags.String("public-key", "", "hex-encoded signing public key")
payloadFile := flags.String("payload-file", "", "optional file for the raw HSM signing payload")
if err := flags.Parse(args); err != nil {
return err
}
if *publicKeyHex == "" || flags.NArg() != 1 {
return fmt.Errorf("prepare-send requires --public-key and one request file")
}
publicKey, err := crypto.NewPublicKeyFromString(strings.TrimPrefix(*publicKeyHex, "0x"))
if err != nil {
return fmt.Errorf("decode public key: %w", err)
}
request := new(signer.SendRequest)
if err = decodeFile(flags.Arg(0), request); err != nil {
return fmt.Errorf("read request: %w", err)
}
prepared, err := signer.PrepareSend(*request, publicKey)
if err != nil {
return err
}
if *payloadFile != "" {
payload, decodeErr := hex.DecodeString(prepared.SigningPayload)
if decodeErr != nil {
return fmt.Errorf("decode signing payload: %w", decodeErr)
}
if writeErr := os.WriteFile(*payloadFile, payload, 0600); writeErr != nil {
return fmt.Errorf("write signing payload: %w", writeErr)
}
}
return writeJSON(stdout, prepared)
}

// finalizeSend attaches and verifies a signature produced by an external HSM.
func finalizeSend(args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("finalize-send", flag.ContinueOnError)
flags.SetOutput(stderr)
signatureFile := flags.String("signature-file", "", "file containing the HSM signature")
signatureFormat := flags.String("signature-format", signer.SignatureFormatRaw, "signature format: raw or der")
signatureEncoding := flags.String("signature-encoding", "hex", "signature file encoding: raw, hex, or base64")
if err := flags.Parse(args); err != nil {
return err
}
if *signatureFile == "" || flags.NArg() != 1 {
return fmt.Errorf("finalize-send requires --signature-file and one prepared request file")
}
prepared := new(signer.PreparedSend)
if err := decodeFile(flags.Arg(0), prepared); err != nil {
return fmt.Errorf("read prepared request: %w", err)
}
signature, err := readSignature(*signatureFile, *signatureEncoding)
if err != nil {
return err
}
transaction, err := signer.FinalizeSend(prepared, signature, *signatureFormat)
if err != nil {
return err
}
return writeJSON(stdout, transaction)
}

// readSignature reads a size-limited HSM signature file in a common encoding.
func readSignature(path, encoding string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read signature: %w", err)
}
defer func() { _ = file.Close() }()
encoded, err := io.ReadAll(io.LimitReader(file, maxInputBytes+1))
if err != nil {
return nil, fmt.Errorf("read signature: %w", err)
}
if len(encoded) > maxInputBytes {
return nil, fmt.Errorf("signature exceeds %d bytes", maxInputBytes)
}
switch encoding {
case "raw":
return encoded, nil
case "hex":
decoded, decodeErr := hex.DecodeString(strings.TrimSpace(string(encoded)))
if decodeErr != nil {
return nil, fmt.Errorf("decode hex signature: %w", decodeErr)
}
return decoded, nil
case "base64":
decoded, decodeErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(encoded)))
if decodeErr != nil {
return nil, fmt.Errorf("decode base64 signature: %w", decodeErr)
}
return decoded, nil
default:
return nil, fmt.Errorf("unsupported signature encoding %q", encoding)
}
}

// signSend() parses a send request, unlocks its key, and writes the signed transaction
func signSend(args []string, stdin *os.File, stdout, stderr io.Writer) error {
// define and parse the sign-send flags
Expand Down
69 changes: 69 additions & 0 deletions cmd/signer/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package main

import (
"bytes"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
Expand Down Expand Up @@ -121,3 +123,70 @@ func TestSignSendRejectsUnknownRequestField(t *testing.T) {
t.Fatal("expected request decoding error")
}
}

func TestPrepareAndFinalizeSendCommands(t *testing.T) {
directory := t.TempDir()
key := crypto.BytesToED25519Private(ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x24}, ed25519.SeedSize)))
request := signer.SendRequest{
Recipient: "502c0b3d6ccd1c6f164aa5536b2ba2cb9e80c711",
AmountUCNPY: 1_000_000,
FeeUCNPY: 10_000,
CreatedHeight: 2_044_370,
NetworkID: 1,
ChainID: 1,
Time: 1_786_569_463_230_678,
}
requestJSON, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
requestPath := filepath.Join(directory, "request.json")
if err = os.WriteFile(requestPath, requestJSON, 0600); err != nil {
t.Fatal(err)
}

payloadPath := filepath.Join(directory, "payload.bin")
var preparedJSON, errorOutput bytes.Buffer
if err = run([]string{
"prepare-send", "--public-key", key.PublicKey().String(), "--payload-file", payloadPath, requestPath,
}, nil, &preparedJSON, &errorOutput); err != nil {
t.Fatalf("prepare: %v; stderr: %s", err, errorOutput.String())
}
prepared := new(signer.PreparedSend)
if err = json.Unmarshal(preparedJSON.Bytes(), prepared); err != nil {
t.Fatal(err)
}
payload, err := hex.DecodeString(prepared.SigningPayload)
if err != nil {
t.Fatal(err)
}
payloadFile, err := os.ReadFile(payloadPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(payloadFile, payload) {
t.Fatal("payload file does not match prepared signing payload")
}
signaturePath := filepath.Join(directory, "signature.hex")
if err = os.WriteFile(signaturePath, []byte(hex.EncodeToString(key.Sign(payload))+"\n"), 0600); err != nil {
t.Fatal(err)
}
preparedPath := filepath.Join(directory, "prepared.json")
if err = os.WriteFile(preparedPath, preparedJSON.Bytes(), 0600); err != nil {
t.Fatal(err)
}

var signedJSON bytes.Buffer
if err = run([]string{
"finalize-send", "--signature-file", signaturePath, preparedPath,
}, nil, &signedJSON, &errorOutput); err != nil {
t.Fatalf("finalize: %v; stderr: %s", err, errorOutput.String())
}
transaction := new(lib.Transaction)
if err = json.Unmarshal(signedJSON.Bytes(), transaction); err != nil {
t.Fatal(err)
}
if _, err = signer.Inspect(transaction); err != nil {
t.Fatalf("inspect finalized transaction: %v", err)
}
}
68 changes: 68 additions & 0 deletions lib/crypto/signer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ The package performs no network or filesystem operations. It constructs the
same protobuf transaction accepted by `POST /v1/tx`, signs the canonical bytes
returned by `Transaction.GetSignBytes`, and immediately verifies the result.

The `signer` executable also supports external HSMs without embedding a
vendor-specific HSM SDK. `prepare-send` emits the exact bytes the HSM must sign,
and `finalize-send` accepts the resulting signature and emits `/v1/tx`-ready
JSON. The private key never enters the signer process.

## Security boundary

The online system supplies these non-secret fields:
Expand Down Expand Up @@ -124,3 +129,66 @@ non-zero status if any signed byte was altered.
Never provide a password or raw private key as a command-line argument or an
environment variable. Both commonly leak through process inspection, logs, or
diagnostic capture.

## External HSM use

The portable workflow supports these public keys:

| Key type | Public key | `signingPayload` | HSM result |
| --- | --- | --- | --- |
| Ed25519 | 32 bytes | Raw canonical protobuf sign bytes | 64-byte raw signature |
| secp256k1 | 33-byte compressed | SHA-256 digest of the sign bytes | Raw `r || s` or ASN.1 DER |
| Ethereum-style secp256k1 | 64-byte `x || y` | SHA-256 digest of the sign bytes | Raw `r || s` or ASN.1 DER |

Prepare the request using the public key. Public keys may be passed on the
command line because they are not secret:

```sh
signer prepare-send \
--public-key "$PUBLIC_KEY_HEX" \
--payload-file payload.bin \
request.json > prepared.json
```

`prepared.json` contains the unsigned transaction, its canonical protobuf sign
bytes, and a hex `signingPayload`; `payload.bin` contains those exact decoded
bytes for an HSM CLI or SDK. The accompanying `signingPayloadType` and `hash`
fields are authoritative:

- Ed25519: sign the decoded `signingPayload` as a raw message using pure
Ed25519. Do not pre-hash it or use Ed25519ph.
- secp256k1: sign the decoded 32-byte digest directly. For PKCS#11 this is
normally `CKM_ECDSA`, not `CKM_ECDSA_SHA256`; the latter would hash the digest
a second time.

Save the HSM result as raw bytes, hex, or base64, then finalize it. PKCS#11
ECDSA commonly returns raw `r || s`, while several managed KMS products return
ASN.1 DER:

```sh
# Raw r || s encoded as hex (the defaults)
signer finalize-send \
--signature-file signature.hex \
prepared.json > signed.json

# DER signature emitted as binary
signer finalize-send \
--signature-file signature.der \
--signature-format der \
--signature-encoding raw \
prepared.json > signed.json
```

The finalizer recomputes every prepared field, converts DER when requested,
normalizes secp256k1 signatures to low-S, attaches the public key and signature,
and verifies the complete transaction. It rejects altered prepared requests,
unsupported key types, invalid signature encodings, and signatures that do not
match the transaction.

Broadcast `signed.json` directly:

```sh
curl -X POST "$CANOPY_RPC/v1/tx" \
-H 'Content-Type: application/json' \
--data-binary @signed.json
```
Loading
Loading