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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user