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:
139
cmd/pqsign/main.go
Normal file
139
cmd/pqsign/main.go
Normal file
@@ -0,0 +1,139 @@
|
||||
//go:build js && wasm
|
||||
|
||||
// Command pqsign exposes hybrid post-quantum signing to the browser.
|
||||
//
|
||||
// WebCrypto has Ed25519 but no ML-DSA, so the post-quantum half has to come
|
||||
// from somewhere. Compiling the same pkg/pqid the server verifies with means
|
||||
// there is exactly one implementation of the scheme in the project: a client
|
||||
// and server that disagreed about signing would be a very expensive bug to
|
||||
// find, and this makes it impossible by construction.
|
||||
//
|
||||
// Build:
|
||||
//
|
||||
// GOOS=js GOARCH=wasm go build -o cmd/arcade/static/pqsign.wasm ./cmd/pqsign
|
||||
//
|
||||
// The private key never leaves the browser. It is generated here, exported for
|
||||
// the page to store, and re-imported on the next visit.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"syscall/js"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/pqid"
|
||||
)
|
||||
|
||||
func main() {
|
||||
js.Global().Set("qaPQ", js.ValueOf(map[string]any{
|
||||
"generateKey": js.FuncOf(generateKey),
|
||||
"sign": js.FuncOf(sign),
|
||||
"publicKey": js.FuncOf(publicKey),
|
||||
"sizes": js.FuncOf(sizes),
|
||||
}))
|
||||
|
||||
// A WASM module's main must not return, or the exported functions are
|
||||
// torn down with it.
|
||||
select {}
|
||||
}
|
||||
|
||||
// result wraps a value or an error in the shape the page expects, so JavaScript
|
||||
// never has to distinguish a thrown Go panic from a returned failure.
|
||||
func result(value any, err error) any {
|
||||
if err != nil {
|
||||
return map[string]any{"error": err.Error()}
|
||||
}
|
||||
return map[string]any{"ok": value}
|
||||
}
|
||||
|
||||
// generateKey creates a hybrid keypair and returns both halves hex-encoded.
|
||||
//
|
||||
// The private half is handed to the page to persist. That is unavoidable —
|
||||
// the browser is where signing happens — but it never crosses the network.
|
||||
func generateKey(this js.Value, args []js.Value) any {
|
||||
pub, priv, err := pqid.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
|
||||
edSeed := priv.Ed.Seed()
|
||||
pqBytes, err := priv.PQ.MarshalBinary()
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
|
||||
return result(map[string]any{
|
||||
"public": pub.Hex(),
|
||||
"ed_seed": hex.EncodeToString(edSeed),
|
||||
"pq_key": hex.EncodeToString(pqBytes),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// sign produces both signatures over a hex-encoded message.
|
||||
//
|
||||
// qaPQ.sign(edSeedHex, pqKeyHex, messageHex) -> {ok: signatureHex}
|
||||
func sign(this js.Value, args []js.Value) any {
|
||||
if len(args) != 3 {
|
||||
return result(nil, errArgs("sign expects (edSeed, pqKey, message)"))
|
||||
}
|
||||
|
||||
priv, err := restore(args[0].String(), args[1].String())
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
msg, err := hex.DecodeString(args[2].String())
|
||||
if err != nil {
|
||||
return result(nil, errArgs("message is not hex"))
|
||||
}
|
||||
|
||||
sig, err := pqid.Sign(priv, msg)
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
return result(hex.EncodeToString(sig), nil)
|
||||
}
|
||||
|
||||
// publicKey re-derives the public half from stored private material, so the
|
||||
// page never has to store the public key separately and cannot store a pair
|
||||
// that does not match.
|
||||
func publicKey(this js.Value, args []js.Value) any {
|
||||
if len(args) != 2 {
|
||||
return result(nil, errArgs("publicKey expects (edSeed, pqKey)"))
|
||||
}
|
||||
priv, err := restore(args[0].String(), args[1].String())
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
pub, err := pqid.PublicFromPrivate(priv)
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
return result(pub.Hex(), nil)
|
||||
}
|
||||
|
||||
// sizes lets the page sanity-check what it stored without hardcoding lengths
|
||||
// that could drift from the Go side.
|
||||
func sizes(this js.Value, args []js.Value) any {
|
||||
return result(map[string]any{
|
||||
"public_key": pqid.PublicKeySize,
|
||||
"signature": pqid.SignatureSize,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func restore(edSeedHex, pqKeyHex string) (*pqid.PrivateKey, error) {
|
||||
edSeed, err := hex.DecodeString(edSeedHex)
|
||||
if err != nil {
|
||||
return nil, errArgs("ed seed is not hex")
|
||||
}
|
||||
pqBytes, err := hex.DecodeString(pqKeyHex)
|
||||
if err != nil {
|
||||
return nil, errArgs("pq key is not hex")
|
||||
}
|
||||
return pqid.PrivateFromBytes(edSeed, pqBytes)
|
||||
}
|
||||
|
||||
type argError string
|
||||
|
||||
func (e argError) Error() string { return string(e) }
|
||||
|
||||
func errArgs(msg string) error { return argError("pqsign: " + msg) }
|
||||
Reference in New Issue
Block a user