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>
140 lines
3.9 KiB
Go
140 lines
3.9 KiB
Go
//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) }
|