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:
drjones
2026-08-06 04:10:51 +00:00
parent 8af6fd585e
commit b0f07f63ff
11 changed files with 1170 additions and 5 deletions

View File

@@ -26,6 +26,7 @@ import (
"github.com/drjones/quantum-arcade/pkg/fixed"
"github.com/drjones/quantum-arcade/pkg/identity"
"github.com/drjones/quantum-arcade/pkg/ledger"
"github.com/drjones/quantum-arcade/pkg/lightning"
"github.com/drjones/quantum-arcade/pkg/room"
"github.com/drjones/quantum-arcade/pkg/scratch"
"github.com/drjones/quantum-arcade/pkg/sim"
@@ -52,6 +53,7 @@ type server struct {
rooms map[string]*room.Room
tournaments *tournament.Service
hubs map[string]*gameHub
ln *lightning.Service // Lightning deposit/withdrawal
// Sessions live in Redis rather than instance memory. With several cloned
// instances behind one endpoint, a token issued by one must be accepted by
@@ -130,6 +132,38 @@ func main() {
defer s.node.Stop(context.Background())
log.Printf("instance %s (%s) advertising %s", s.node.ID, s.node.Hostname, s.node.Address)
// ── Lightning (optional: dev faucet works without it) ──
if url := os.Getenv("ALBY_URL"); url != "" {
token := os.Getenv("ALBY_TOKEN")
if token == "" {
log.Printf("ALBY_URL set but ALBY_TOKEN empty — Lightning disabled")
} else {
albyNode := lightning.NewAlbyNode(url, token)
limits := lightning.DefaultLimits()
s.ln = lightning.New(albyNode, s.ledger, s.pool, limits)
log.Printf("Lightning node connected: %s", url)
// Process queued withdrawals every 15 seconds.
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n, err := s.ln.ProcessWithdrawals(ctx, 10); err != nil {
log.Printf("lightning: withdrawal processor: %v", err)
} else if n > 0 {
log.Printf("lightning: paid %d withdrawals", n)
}
}
}
}()
}
} else {
log.Printf("ALBY_URL not set — Lightning disabled (dev faucet only)")
}
// One hub per game. Each hub campaigns for leadership: the winner drives
// the rounds and publishes frames, the rest relay those frames to their
// own clients. Roles are renegotiated continuously, so losing an instance
@@ -192,6 +226,9 @@ func (s *server) routes() http.Handler {
mux.HandleFunc("GET /api/balance", s.handleBalance)
mux.HandleFunc("GET /api/history", s.handleHistory)
mux.HandleFunc("POST /api/transfer", s.handleTransfer)
mux.HandleFunc("POST /api/deposit", s.handleDeposit)
mux.HandleFunc("POST /api/deposit/check", s.handleDepositCheck)
mux.HandleFunc("POST /api/withdraw", s.handleWithdraw)
mux.HandleFunc("GET /api/games", s.handleGames)
mux.HandleFunc("POST /api/bet", s.handleBet)
mux.HandleFunc("POST /api/cashout", s.handleCashout)
@@ -429,6 +466,96 @@ func (s *server) handleTransfer(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
}
// ───────── Lightning deposit/withdrawal ─────────
type depositRequest struct {
AmountSat int64 `json:"amount_sats"`
}
func (s *server) handleDeposit(w http.ResponseWriter, r *http.Request) {
if s.ln == nil {
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
return
}
acctID, _, ok := s.account(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "sign in first")
return
}
var req depositRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AmountSat < 1 {
writeErr(w, http.StatusBadRequest, "amount_sats required (>=1)")
return
}
inv, err := s.ln.RequestDeposit(r.Context(), acctID, req.AmountSat*1000)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"payment_hash": inv.PaymentHash,
"invoice": inv.Bolt11,
"amount_sats": inv.AmountMsat / 1000,
"expires_at": inv.ExpiresAt,
})
}
type depositCheckRequest struct {
PaymentHash string `json:"payment_hash"`
}
func (s *server) handleDepositCheck(w http.ResponseWriter, r *http.Request) {
if s.ln == nil {
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
return
}
var req depositCheckRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.PaymentHash == "" {
writeErr(w, http.StatusBadRequest, "payment_hash required")
return
}
credited, err := s.ln.SettleDeposit(r.Context(), req.PaymentHash)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"settled": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"settled": true,
"credited_msat": credited,
})
}
type withdrawRequest struct {
Bolt11 string `json:"bolt11"`
AmountSat int64 `json:"amount_sats"`
}
func (s *server) handleWithdraw(w http.ResponseWriter, r *http.Request) {
if s.ln == nil {
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
return
}
acctID, _, ok := s.account(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "sign in first")
return
}
var req withdrawRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Bolt11 == "" || req.AmountSat < 1 {
writeErr(w, http.StatusBadRequest, "bolt11 and amount_sats required (>=1)")
return
}
id, err := s.ln.RequestWithdrawal(r.Context(), acctID, req.Bolt11, req.AmountSat*1000)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"withdrawal_id": id,
"status": "queued",
})
}
func (s *server) handleGames(w http.ResponseWriter, r *http.Request) {
// Serve the last frame each hub saw rather than the local room object:
// on an instance that does not lead a game, the local room is idle and