//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) }