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