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

@@ -169,3 +169,40 @@ func Verify(pub *PublicKey, msg, sig []byte) error {
}
return nil
}
// PrivateFromBytes reconstructs a private key from stored material.
//
// The Ed25519 half is stored as its 32-byte seed rather than the expanded
// key, because the seed is the canonical form and cannot encode an
// inconsistent pair. The ML-DSA half is stored in its own binary encoding.
func PrivateFromBytes(edSeed, pqKey []byte) (*PrivateKey, error) {
if len(edSeed) != ed25519.SeedSize {
return nil, fmt.Errorf("%w: ed25519 seed is %d bytes, want %d",
ErrMalformedKey, len(edSeed), ed25519.SeedSize)
}
var pq mldsa65.PrivateKey
if err := pq.UnmarshalBinary(pqKey); err != nil {
return nil, fmt.Errorf("%w: ML-DSA private key: %v", ErrMalformedKey, err)
}
return &PrivateKey{
Ed: ed25519.NewKeyFromSeed(edSeed),
PQ: &pq,
}, nil
}
// PublicFromPrivate derives the public half.
//
// Deriving rather than storing means a client cannot present a public key that
// does not match the key it signs with — a mismatch that would otherwise only
// surface as a confusing authentication failure.
func PublicFromPrivate(priv *PrivateKey) (*PublicKey, error) {
edPub, ok := priv.Ed.Public().(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("%w: ed25519 private key has no public half", ErrMalformedKey)
}
pqPub, ok := priv.PQ.Public().(*mldsa65.PublicKey)
if !ok {
return nil, fmt.Errorf("%w: ML-DSA private key has no public half", ErrMalformedKey)
}
return &PublicKey{Ed: edPub, PQ: pqPub}, nil
}

View File

@@ -208,3 +208,59 @@ func BenchmarkGenerateKey(b *testing.B) {
}
}
}
// A key stored by the browser and restored on the next visit must produce
// signatures the server still accepts.
func TestPrivateKeyRoundTripThroughStorage(t *testing.T) {
pub, priv := newKey(t)
// What the browser would persist.
edSeed := priv.Ed.Seed()
pqBytes, err := priv.PQ.MarshalBinary()
if err != nil {
t.Fatal(err)
}
restored, err := pqid.PrivateFromBytes(edSeed, pqBytes)
if err != nil {
t.Fatal(err)
}
msg := []byte("a challenge issued after the page reloaded")
sig, err := pqid.Sign(restored, msg)
if err != nil {
t.Fatal(err)
}
if err := pqid.Verify(pub, msg, sig); err != nil {
t.Fatalf("a restored key produced a signature the original public key rejects: %v", err)
}
}
// The public key must be derivable, so a client cannot present one that does
// not match what it signs with.
func TestPublicKeyDerivesFromPrivate(t *testing.T) {
pub, priv := newKey(t)
derived, err := pqid.PublicFromPrivate(priv)
if err != nil {
t.Fatal(err)
}
if derived.Hex() != pub.Hex() {
t.Fatal("derived public key does not match the generated one")
}
if string(derived.ID()) != string(pub.ID()) {
t.Fatal("derived public key has a different account id")
}
}
func TestMalformedStoredKeysRejected(t *testing.T) {
_, priv := newKey(t)
pqBytes, _ := priv.PQ.MarshalBinary()
if _, err := pqid.PrivateFromBytes([]byte("short"), pqBytes); !errors.Is(err, pqid.ErrMalformedKey) {
t.Errorf("short ed seed gave %v, want ErrMalformedKey", err)
}
if _, err := pqid.PrivateFromBytes(priv.Ed.Seed(), []byte("nonsense")); !errors.Is(err, pqid.ErrMalformedKey) {
t.Errorf("bad pq key gave %v, want ErrMalformedKey", err)
}
}