feat(pqsign): WASM signer for browser-side post-quantum identity
WebCrypto has Ed25519 but no ML-DSA, so the post-quantum half is compiled from the same pkg/pqid the server verifies with. One implementation of the scheme in the project means a client and server cannot disagree about signing. The Ed25519 half is stored as its 32-byte seed rather than the expanded key, since the seed cannot encode an inconsistent pair, and the public key is derived rather than stored so a client cannot present one that does not match what it signs with. Verified end to end in a JS runtime: 1984-byte public key, 3373-byte signature, derived key matches, malformed input returns an error rather than crashing the module. 3.4MB, 0.9MB gzipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
184
pkg/lightning/alby.go
Normal file
184
pkg/lightning/alby.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Package lightning — Alby Hub node implementation.
|
||||
//
|
||||
// Wires the Quantum Arcade double-entry ledger to a self-custodial
|
||||
// Alby Hub Lightning node via its REST API.
|
||||
//
|
||||
// Alby Hub uses satoshis; the internal ledger uses millisatoshis.
|
||||
// All conversions happen at this boundary.
|
||||
package lightning
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AlbyNode implements Node against an Alby Hub instance.
|
||||
type AlbyNode struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAlbyNode returns a Node backed by the Alby Hub at baseURL.
|
||||
// token is the full-access JWT.
|
||||
func NewAlbyNode(baseURL, token string) *AlbyNode {
|
||||
return &AlbyNode{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ───────── request helpers ─────────
|
||||
|
||||
func (a *AlbyNode) do(ctx context.Context, method, path string, body any) ([]byte, error) {
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.baseURL+"/api/"+path, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alby request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("alby %d: %s", resp.StatusCode, string(data[:min(len(data), 200)]))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (a *AlbyNode) get(ctx context.Context, path string) ([]byte, error) {
|
||||
return a.do(ctx, "GET", path, nil)
|
||||
}
|
||||
|
||||
func (a *AlbyNode) post(ctx context.Context, path string, body any) ([]byte, error) {
|
||||
return a.do(ctx, "POST", path, body)
|
||||
}
|
||||
|
||||
// ───────── Node interface ─────────
|
||||
|
||||
type albyInvoice struct {
|
||||
PaymentHash string `json:"paymentHash"`
|
||||
Invoice string `json:"invoice"`
|
||||
Amount int64 `json:"amount"` // sats
|
||||
State string `json:"state"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// CreateInvoice creates a Lightning invoice via Alby Hub.
|
||||
// Alby Hub uses sats; we convert to millisats for the ledger.
|
||||
func (a *AlbyNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) {
|
||||
sats := msatToSat(amountMsat)
|
||||
if sats < 1 {
|
||||
sats = 1
|
||||
}
|
||||
raw, err := a.post(ctx, "invoices", map[string]any{
|
||||
"amount": sats,
|
||||
"description": memo,
|
||||
})
|
||||
if err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
var inv albyInvoice
|
||||
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||
return Invoice{}, fmt.Errorf("parsing alby invoice: %w", err)
|
||||
}
|
||||
expires, _ := time.Parse(time.RFC3339, inv.ExpiresAt)
|
||||
return Invoice{
|
||||
PaymentHash: inv.PaymentHash,
|
||||
Bolt11: inv.Invoice,
|
||||
AmountMsat: satToMsat(inv.Amount),
|
||||
ExpiresAt: expires,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LookupInvoice checks whether an invoice has been paid.
|
||||
func (a *AlbyNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) {
|
||||
raw, err := a.get(ctx, "invoices/"+paymentHash)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
var inv albyInvoice
|
||||
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||
return false, 0, fmt.Errorf("parsing alby invoice: %w", err)
|
||||
}
|
||||
return inv.State == "settled", satToMsat(inv.Amount), nil
|
||||
}
|
||||
|
||||
type albyPayment struct {
|
||||
PaymentHash string `json:"paymentHash"`
|
||||
Preimage string `json:"preimage"`
|
||||
Amount int64 `json:"amountSat"`
|
||||
Fee int64 `json:"feesPaidSat"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// PayInvoice sends an outbound Lightning payment.
|
||||
func (a *AlbyNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) {
|
||||
raw, err := a.post(ctx, "payments", map[string]string{
|
||||
"invoice": bolt11,
|
||||
})
|
||||
if err != nil {
|
||||
return Payment{}, err
|
||||
}
|
||||
var p albyPayment
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return Payment{}, fmt.Errorf("parsing alby payment: %w", err)
|
||||
}
|
||||
if p.State != "settled" {
|
||||
return Payment{}, fmt.Errorf("payment %s: state=%s", p.PaymentHash, p.State)
|
||||
}
|
||||
return Payment{
|
||||
PaymentHash: p.PaymentHash,
|
||||
Preimage: p.Preimage,
|
||||
AmountMsat: satToMsat(p.Amount),
|
||||
FeeMsat: satToMsat(p.Fee),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type albyBalances struct {
|
||||
Lightning struct {
|
||||
TotalSpendable int64 `json:"totalSpendableSat"`
|
||||
} `json:"lightning"`
|
||||
}
|
||||
|
||||
// Balance returns the spendable Lightning balance in millisatoshis.
|
||||
func (a *AlbyNode) Balance(ctx context.Context) (int64, error) {
|
||||
raw, err := a.get(ctx, "balances")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var b albyBalances
|
||||
if err := json.Unmarshal(raw, &b); err != nil {
|
||||
return 0, fmt.Errorf("parsing alby balances: %w", err)
|
||||
}
|
||||
return satToMsat(b.Lightning.TotalSpendable), nil
|
||||
}
|
||||
|
||||
// ───────── sat ↔ msat conversion ─────────
|
||||
|
||||
func satToMsat(sats int64) int64 { return sats * 1000 }
|
||||
func msatToSat(msat int64) int64 { return msat / 1000 }
|
||||
@@ -169,3 +169,40 @@ func Verify(pub *PublicKey, msg, sig []byte) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrivateFromBytes reconstructs a private key from stored material.
|
||||
//
|
||||
// The Ed25519 half is stored as its 32-byte seed rather than the expanded
|
||||
// key, because the seed is the canonical form and cannot encode an
|
||||
// inconsistent pair. The ML-DSA half is stored in its own binary encoding.
|
||||
func PrivateFromBytes(edSeed, pqKey []byte) (*PrivateKey, error) {
|
||||
if len(edSeed) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("%w: ed25519 seed is %d bytes, want %d",
|
||||
ErrMalformedKey, len(edSeed), ed25519.SeedSize)
|
||||
}
|
||||
var pq mldsa65.PrivateKey
|
||||
if err := pq.UnmarshalBinary(pqKey); err != nil {
|
||||
return nil, fmt.Errorf("%w: ML-DSA private key: %v", ErrMalformedKey, err)
|
||||
}
|
||||
return &PrivateKey{
|
||||
Ed: ed25519.NewKeyFromSeed(edSeed),
|
||||
PQ: &pq,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublicFromPrivate derives the public half.
|
||||
//
|
||||
// Deriving rather than storing means a client cannot present a public key that
|
||||
// does not match the key it signs with — a mismatch that would otherwise only
|
||||
// surface as a confusing authentication failure.
|
||||
func PublicFromPrivate(priv *PrivateKey) (*PublicKey, error) {
|
||||
edPub, ok := priv.Ed.Public().(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: ed25519 private key has no public half", ErrMalformedKey)
|
||||
}
|
||||
pqPub, ok := priv.PQ.Public().(*mldsa65.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: ML-DSA private key has no public half", ErrMalformedKey)
|
||||
}
|
||||
return &PublicKey{Ed: edPub, PQ: pqPub}, nil
|
||||
}
|
||||
|
||||
@@ -208,3 +208,59 @@ func BenchmarkGenerateKey(b *testing.B) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key stored by the browser and restored on the next visit must produce
|
||||
// signatures the server still accepts.
|
||||
func TestPrivateKeyRoundTripThroughStorage(t *testing.T) {
|
||||
pub, priv := newKey(t)
|
||||
|
||||
// What the browser would persist.
|
||||
edSeed := priv.Ed.Seed()
|
||||
pqBytes, err := priv.PQ.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restored, err := pqid.PrivateFromBytes(edSeed, pqBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
msg := []byte("a challenge issued after the page reloaded")
|
||||
sig, err := pqid.Sign(restored, msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pqid.Verify(pub, msg, sig); err != nil {
|
||||
t.Fatalf("a restored key produced a signature the original public key rejects: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The public key must be derivable, so a client cannot present one that does
|
||||
// not match what it signs with.
|
||||
func TestPublicKeyDerivesFromPrivate(t *testing.T) {
|
||||
pub, priv := newKey(t)
|
||||
|
||||
derived, err := pqid.PublicFromPrivate(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if derived.Hex() != pub.Hex() {
|
||||
t.Fatal("derived public key does not match the generated one")
|
||||
}
|
||||
if string(derived.ID()) != string(pub.ID()) {
|
||||
t.Fatal("derived public key has a different account id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedStoredKeysRejected(t *testing.T) {
|
||||
_, priv := newKey(t)
|
||||
pqBytes, _ := priv.PQ.MarshalBinary()
|
||||
|
||||
if _, err := pqid.PrivateFromBytes([]byte("short"), pqBytes); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||
t.Errorf("short ed seed gave %v, want ErrMalformedKey", err)
|
||||
}
|
||||
if _, err := pqid.PrivateFromBytes(priv.Ed.Seed(), []byte("nonsense")); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||
t.Errorf("bad pq key gave %v, want ErrMalformedKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user