diff --git a/go.mod b/go.mod index 90dfb76..080e152 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/fil-forge/ucantone go 1.25.0 require ( + filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e github.com/alanshaw/dag-json-gen v0.0.8 github.com/gobwas/glob v0.2.3 github.com/ipfs/go-cid v0.6.1 diff --git a/go.sum b/go.sum index cbcf1a2..367dba5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= github.com/alanshaw/dag-json-gen v0.0.8 h1:Y0SfO2bp9ECDvcNbw6aQ91jmnfLC7UgRDshCeASDfT8= github.com/alanshaw/dag-json-gen v0.0.8/go.mod h1:v1YBZcS4B355MqxtyQr+fGNbEhm0CzHd+gOqOO/MZ+I= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/multikey/mldsa44/signer.go b/multikey/mldsa44/signer.go new file mode 100644 index 0000000..dd658dd --- /dev/null +++ b/multikey/mldsa44/signer.go @@ -0,0 +1,160 @@ +package mldsa44 + +import ( + "fmt" + + "filippo.io/mldsa" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/mldsa44/verifier" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/varsig" + varsigmldsa "github.com/fil-forge/ucantone/varsig/algorithm/mldsa" + "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" + "github.com/multiformats/go-varint" +) + +// Code is the Multicodec code for `mldsa44-priv`. It is not (yet) present in the +// go-multicodec table, so it is spelled out here to keep `did:key` and signer +// encodings identical to those produced before the `multikey` restructuring. +// +// https://github.com/multiformats/multicodec/blob/6bd718d6cb68e6714dea92758d7654aa1f9974b3/table.csv#L224 +const Code = multicodec.Code(0x131a) + +var tagSize = varint.UvarintSize(uint64(Code)) + +// ML-DSA-44 private keys are derived from a 32 byte seed. +const keySize = mldsa.PrivateKeySize + +var size = tagSize + keySize + +func Generate() (Signer, error) { + sk, err := mldsa.GenerateKey(mldsa.MLDSA44()) + if err != nil { + return nil, fmt.Errorf("generating ML-DSA-44 key: %w", err) + } + s := make(Signer, size) + varint.PutUvarint(s, uint64(Code)) + copy(s[tagSize:], sk.Bytes()) + return s, nil +} + +func GenerateIssuer() (multikey.Issuer, error) { + signer, err := Generate() + if err != nil { + return nil, fmt.Errorf("generating signer: %w", err) + } + return multikey.KeyIssuer(signer), nil +} + +// Parse parses a multibase encoded string containing an ML-DSA-44 signer +// multiformat varint (0x131a) + 32 byte ML-DSA-44 private key seed. +func Parse(str string) (Signer, error) { + _, bytes, err := multibase.Decode(str) + if err != nil { + return nil, fmt.Errorf("decoding multibase string: %w", err) + } + return Decode(bytes) +} + +func Format(signer multikey.Signer) string { + s, _ := multibase.Encode(multibase.Base64pad, signer.Bytes()) + return s +} + +// Decode decodes a buffer of an ML-DSA-44 signer multiformat varint (0x131a) + +// 32 byte ML-DSA-44 private key seed. +func Decode(b []byte) (Signer, error) { + if len(b) != size { + return nil, fmt.Errorf("invalid length: %d wanted: %d", len(b), size) + } + + skc, _, err := varint.FromUvarint(b) + if err != nil { + return nil, fmt.Errorf("reading private key uvarint: %w", err) + } + if skc != uint64(Code) { + return nil, fmt.Errorf("invalid private key codec: %s [0x%02x], expected: %s [0x%02x]", multicodec.Code(skc), skc, Code, uint64(Code)) + } + + if _, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), b[tagSize:]); err != nil { + return nil, fmt.Errorf("creating private key: %w", err) + } + + s := make(Signer, size) + copy(s, b) + + return s, nil +} + +func Encode(signer Signer) []byte { + return signer.Bytes() +} + +// FromRaw takes raw 32 byte ML-DSA-44 private key seed bytes and tags with the +// ML-DSA-44 signer multiformat code, returning an ML-DSA-44 signer. +func FromRaw(b []byte) (Signer, error) { + if len(b) != keySize { + return nil, fmt.Errorf("invalid length: %d wanted: %d", len(b), keySize) + } + s := make(Signer, size) + varint.PutUvarint(s, uint64(Code)) + copy(s[tagSize:], b) + return s, nil +} + +type Signer []byte + +var _ multikey.Signer = (Signer)(nil) + +func (s Signer) Code() multicodec.Code { + return Code +} + +func (s Signer) SignatureAlgorithm() varsig.Algorithm { + return varsigmldsa.MLDSA44 +} + +func (s Signer) PrivateKey() any { + sk, _ := mldsa.NewPrivateKey(mldsa.MLDSA44(), s[tagSize:]) + return sk +} + +func (s Signer) PublicKey() any { + return s.verifier().PublicKey() +} + +func (s Signer) Verifier() ucan.Verifier { + return s.verifier() +} + +func (s Signer) verifier() multikey.Verifier { + sk, _ := mldsa.NewPrivateKey(mldsa.MLDSA44(), s[tagSize:]) + v, _ := verifier.FromRaw(sk.PublicKey().Bytes()) + return v +} + +// Bytes returns the private key bytes with multiformat prefix varint. +func (s Signer) Bytes() []byte { + return s +} + +// Raw encodes the bytes of the private key without multiformats tags. +func (s Signer) Raw() []byte { + pk := make([]byte, keySize) + copy(pk, s[tagSize:size]) + return pk +} + +// Sign produces a deterministic ML-DSA-44 signature over msg. Determinism means +// signing the same message with the same key always yields the same signature. +func (s Signer) Sign(msg []byte) []byte { + sk, _ := mldsa.NewPrivateKey(mldsa.MLDSA44(), s[tagSize:]) + sig, _ := sk.SignDeterministic(msg, nil) + return sig +} + +func (s Signer) KeyDID() did.DID { + return s.verifier().KeyDID() +} diff --git a/multikey/mldsa44/signer_test.go b/multikey/mldsa44/signer_test.go new file mode 100644 index 0000000..3801d36 --- /dev/null +++ b/multikey/mldsa44/signer_test.go @@ -0,0 +1,118 @@ +package mldsa44_test + +import ( + "testing" + + "filippo.io/mldsa" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/mldsa44" + "github.com/stretchr/testify/require" +) + +func TestGenerateEncodeDecode(t *testing.T) { + s0, err := mldsa44.Generate() + require.NoError(t, err) + + t.Log(multikey.FormatVerifier(s0.Verifier().(multikey.Verifier))) + + s1, err := mldsa44.Decode(s0.Bytes()) + require.NoError(t, err) + + t.Log(multikey.FormatVerifier(s1.Verifier().(multikey.Verifier))) + require.Equal(t, s0, s1, "private key mismatch") + require.Equal(t, s0.Verifier(), s1.Verifier(), "public key mismatch") +} + +func TestGenerateFormatParse(t *testing.T) { + s0, err := mldsa44.Generate() + require.NoError(t, err) + + t.Log(multikey.FormatVerifier(s0.Verifier().(multikey.Verifier))) + + str := mldsa44.Format(s0) + t.Log(str) + + s1, err := mldsa44.Parse(str) + require.NoError(t, err) + + t.Log(multikey.FormatVerifier(s1.Verifier().(multikey.Verifier))) + require.Equal(t, s0.Verifier(), s1.Verifier(), "public key mismatch") +} + +func TestVerify(t *testing.T) { + s, err := mldsa44.Generate() + require.NoError(t, err) + + msg := []byte("testy") + sig := s.Sign(msg) + + res := s.Verifier().Verify(msg, sig) + require.True(t, res) +} + +// TestSignerRaw asserts that signing is deterministic: reconstructing the +// private key from the signer's raw seed and signing the same message yields the +// exact same signature bytes. +func TestSignerRaw(t *testing.T) { + s, err := mldsa44.Generate() + require.NoError(t, err) + + msg := []byte{1, 2, 3} + raw := s.Raw() + sk, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), raw) + require.NoError(t, err) + sig, err := sk.SignDeterministic(msg, nil) + require.NoError(t, err) + + require.Equal(t, s.Sign(msg), sig) +} + +func TestFromRaw(t *testing.T) { + t.Run("round trip", func(t *testing.T) { + sk, err := mldsa.GenerateKey(mldsa.MLDSA44()) + require.NoError(t, err) + + s, err := mldsa44.FromRaw(sk.Bytes()) + require.NoError(t, err) + + require.Equal(t, sk.Bytes(), s.Raw()) + }) + + t.Run("invalid length", func(t *testing.T) { + _, err := mldsa44.FromRaw([]byte{}) + require.Error(t, err) + require.ErrorContains(t, err, "invalid length") + }) +} + +// TestGenerateIssuer covers the full issuer round-trip a caller sees: generate +// an issuer, sign with it, and verify the signature through the issuer's own +// verifier. +func TestGenerateIssuer(t *testing.T) { + issuer, err := mldsa44.GenerateIssuer() + require.NoError(t, err) + require.Equal(t, issuer.KeyDID(), issuer.DID()) + + msg := []byte("testy") + sig := issuer.Sign(msg) + require.True(t, issuer.Verifier().Verify(msg, sig)) +} + +// TestParseKeyDIDRoundTrip covers the `did:key` string -> [multikey.Parse] -> +// verifier -> verify path, confirming the verifier package's Decoder is +// registered and that a signature made by the signer verifies against the parsed +// verifier. +func TestParseKeyDIDRoundTrip(t *testing.T) { + s, err := mldsa44.Generate() + require.NoError(t, err) + + msg := []byte("post-quantum") + sig := s.Sign(msg) + + keyDID := s.KeyDID() + v, err := multikey.Parse(keyDID.Identifier()) + require.NoError(t, err) + + require.Equal(t, keyDID, v.KeyDID()) + require.True(t, v.Verify(msg, sig)) +} diff --git a/multikey/mldsa44/verifier/verifier.go b/multikey/mldsa44/verifier/verifier.go new file mode 100644 index 0000000..795effc --- /dev/null +++ b/multikey/mldsa44/verifier/verifier.go @@ -0,0 +1,126 @@ +package verifier + +import ( + "errors" + "fmt" + + "filippo.io/mldsa" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/internal/multiformat" + "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" + "github.com/multiformats/go-varint" +) + +func init() { + multikey.Register(Code, Decode) +} + +// Code is the Multicodec code for `mldsa44-pub`. It is not (yet) present in the +// go-multicodec table, so it is spelled out here to keep `did:key` encodings +// identical to those produced before the `multikey` restructuring. +// +// https://github.com/multiformats/multicodec/blob/6bd718d6cb68e6714dea92758d7654aa1f9974b3/table.csv#L181 +const Code = multicodec.Code(0x1210) + +var publicTagSize = varint.UvarintSize(uint64(Code)) + +const keySize = mldsa.MLDSA44PublicKeySize + +var size = publicTagSize + keySize + +func ParseKeyDID(str string) (multikey.Verifier, error) { + did, err := did.Parse(str) + if err != nil { + return nil, fmt.Errorf("invalid DID: %w", err) + } + if did.Method() != "key" { + return nil, fmt.Errorf("invalid DID method: %s, expected: key", did.Method()) + } + code, bytes, err := multibase.Decode(did.Identifier()) + if err != nil { + return nil, err + } + if code != multibase.Base58BTC { + return nil, errors.New("not Base58BTC encoded") + } + return Decode(bytes) +} + +func Format(verifier multikey.Verifier) string { + return verifier.KeyDID().String() +} + +func Decode(b []byte) (multikey.Verifier, error) { + if len(b) != size { + return nil, fmt.Errorf("invalid length: %d wanted: %d", len(b), size) + } + code, _, err := varint.FromUvarint(b) + if err != nil { + return nil, fmt.Errorf("reading uvarint: %w", err) + } + if code != uint64(Code) { + return nil, fmt.Errorf("invalid public key codec: %s [0x%02x], expected: %s [0x%02x]", multicodec.Code(code), code, Code, uint64(Code)) + } + if _, err := mldsa.NewPublicKey(mldsa.MLDSA44(), b[publicTagSize:]); err != nil { + return nil, fmt.Errorf("invalid public key bytes: %w", err) + } + v := make(Verifier, size) + copy(v, b) + return v, nil +} + +func Encode(verifier Verifier) []byte { + return verifier +} + +// FromRaw takes raw ML-DSA-44 public key bytes and tags with the ML-DSA-44 +// verifier multiformat code, returning an ML-DSA-44 verifier. +func FromRaw(b []byte) (Verifier, error) { + if len(b) != keySize { + return nil, fmt.Errorf("invalid length: %d wanted: %d", len(b), keySize) + } + return Verifier(multiformat.TagWith(Code, b)), nil +} + +type Verifier []byte + +var _ multikey.Verifier = (Verifier)(nil) + +func (v Verifier) Code() multicodec.Code { + return Code +} + +func (v Verifier) PublicKey() any { + pk, _ := mldsa.NewPublicKey(mldsa.MLDSA44(), v[publicTagSize:]) + return pk +} + +func (v Verifier) Verify(msg []byte, sig []byte) bool { + pk, err := mldsa.NewPublicKey(mldsa.MLDSA44(), v[publicTagSize:]) + if err != nil { + return false + } + return mldsa.Verify(pk, msg, sig, nil) == nil +} + +func (v Verifier) String() string { + return multikey.FormatVerifier(v) +} + +// Bytes returns the public key bytes with multiformat prefix varint. +func (v Verifier) Bytes() []byte { + return v +} + +// Raw encodes the bytes of the public key without multiformats tags. +func (v Verifier) Raw() []byte { + k := make([]byte, keySize) + copy(k, v[publicTagSize:]) + return k +} + +func (v Verifier) KeyDID() did.DID { + return multikey.KeyDID(v) +} diff --git a/multikey/mldsa44/verifier/verifier_test.go b/multikey/mldsa44/verifier/verifier_test.go new file mode 100644 index 0000000..67f32df --- /dev/null +++ b/multikey/mldsa44/verifier/verifier_test.go @@ -0,0 +1,72 @@ +package verifier_test + +import ( + "testing" + + "filippo.io/mldsa" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/mldsa44" + "github.com/fil-forge/ucantone/multikey/mldsa44/verifier" + "github.com/stretchr/testify/require" +) + +// TestParseKeyDID round-trips a `did:key` string through [verifier.ParseKeyDID]. +// ML-DSA-44 public keys are 1312 bytes, so rather than hardcode a DID we derive +// one from a freshly generated signer. +func TestParseKeyDID(t *testing.T) { + s, err := mldsa44.Generate() + require.NoError(t, err) + + str := s.KeyDID().String() + v, err := verifier.ParseKeyDID(str) + require.NoError(t, err) + require.Equal(t, str, v.KeyDID().String()) +} + +func TestDecode(t *testing.T) { + t.Run("round trip", func(t *testing.T) { + sk, err := mldsa.GenerateKey(mldsa.MLDSA44()) + require.NoError(t, err) + + v, err := verifier.FromRaw(sk.PublicKey().Bytes()) + require.NoError(t, err) + + v2, err := verifier.Decode(v.Bytes()) + require.NoError(t, err) + require.Equal(t, v, v2) + }) +} + +func TestFromRaw(t *testing.T) { + t.Run("round trip", func(t *testing.T) { + sk, err := mldsa.GenerateKey(mldsa.MLDSA44()) + require.NoError(t, err) + + pub := sk.PublicKey().Bytes() + v, err := verifier.FromRaw(pub) + require.NoError(t, err) + + require.Equal(t, pub, v.Raw()) + }) + + t.Run("invalid length", func(t *testing.T) { + _, err := verifier.FromRaw([]byte{}) + require.Error(t, err) + require.ErrorContains(t, err, "invalid length") + }) +} + +// TestParseAndVerify covers the `did:key` -> [multikey.Parse] -> verify path, +// confirming the package's Decoder is registered with the multikey registry via +// its init and that a signature verifies against the parsed verifier. +func TestParseAndVerify(t *testing.T) { + s, err := mldsa44.Generate() + require.NoError(t, err) + + msg := []byte("testy") + sig := s.Sign(msg) + + v, err := multikey.Parse(s.KeyDID().Identifier()) + require.NoError(t, err) + require.True(t, v.Verify(msg, sig)) +} diff --git a/varsig/algorithm/mldsa/mldsa.go b/varsig/algorithm/mldsa/mldsa.go new file mode 100644 index 0000000..4195180 --- /dev/null +++ b/varsig/algorithm/mldsa/mldsa.go @@ -0,0 +1,75 @@ +package mldsa + +import ( + "fmt" + + "github.com/fil-forge/ucantone/varsig" + varint "github.com/multiformats/go-varint" +) + +// Code is the Varsig discriminant for the ML-DSA-44 post-quantum signature +// algorithm. Unlike ECDSA and EdDSA, ML-DSA is a self-contained scheme with no +// curve or hash sub-codes, so it is identified by a single segment. The +// discriminant reuses the ML-DSA-44 public key Multicodec code so that the wire +// encoding matches deployments produced before the `multikey` restructuring. +// +// https://github.com/multiformats/multicodec/blob/6bd718d6cb68e6714dea92758d7654aa1f9974b3/table.csv#L181 +const Code = 0x1210 + +func init() { + // ML-DSA-44 is not one of Varsig's spec-defined algorithms, so it registers + // itself the way the other add-on algorithms do (see the `nonstandard` + // package) rather than being wired in centrally from `varsig`. + varsig.RegisterAlgorithmScheme(varsig.AlgorithmSchemeDef{ + Code: Code, + Name: "ML-DSA-44", + Decoder: Decode, + }) +} + +// Algorithm is the ML-DSA-44 post-quantum signature algorithm. It carries no +// curve or hash sub-codes, so it is represented by a single segment. +type Algorithm struct{} + +// New returns the ML-DSA-44 signature algorithm. +func New() Algorithm { + return Algorithm{} +} + +// Code returns the Varsig discriminant for ML-DSA-44. +func (alg Algorithm) Code() uint64 { + return Code +} + +// Segments returns the sequence of varints that make up this algorithm's +// portion of a Varsig header. ML-DSA-44 has no sub-codes, so this is a single +// segment. +func (alg Algorithm) Segments() []uint64 { + return []uint64{Code} +} + +// Encode encodes the ML-DSA-44 signature algorithm as a varint segment. +func (alg Algorithm) Encode() ([]byte, error) { + size := varint.UvarintSize(Code) + out := make([]byte, size) + varint.PutUvarint(out, Code) + return out, nil +} + +// Decode decodes an ML-DSA-44 signature algorithm segment, returning the +// algorithm and the number of bytes consumed. +func Decode(input []byte) (varsig.Algorithm, int, error) { + code, n, err := varint.FromUvarint(input) + if err != nil { + return nil, 0, err + } + if code != Code { + return nil, n, fmt.Errorf("signature code is not ML-DSA-44: 0x%02x, expected: 0x%02x", code, Code) + } + offset := n + return Algorithm{}, offset, nil +} + +// MLDSA44 is the ML-DSA-44 signature algorithm, mirroring [eddsa.Ed25519] and +// [ecdsa.Secp256k1]. +var MLDSA44 = New() diff --git a/varsig/algorithm/mldsa/mldsa_test.go b/varsig/algorithm/mldsa/mldsa_test.go new file mode 100644 index 0000000..e113777 --- /dev/null +++ b/varsig/algorithm/mldsa/mldsa_test.go @@ -0,0 +1,44 @@ +package mldsa_test + +import ( + "testing" + + "github.com/fil-forge/ucantone/varsig" + "github.com/fil-forge/ucantone/varsig/algorithm/mldsa" + "github.com/stretchr/testify/require" +) + +func TestMldsa(t *testing.T) { + t.Run("round trips", func(t *testing.T) { + alg := mldsa.New() + require.Equal(t, uint64(mldsa.Code), alg.Code()) + require.Equal(t, []uint64{mldsa.Code}, alg.Segments()) + + bytes, err := alg.Encode() + require.NoError(t, err) + + decodedAlg, n, err := mldsa.Decode(bytes) + require.NoError(t, err) + require.Equal(t, len(bytes), n) + + _, ok := decodedAlg.(mldsa.Algorithm) + require.True(t, ok) + }) + + t.Run("fails with wrong code", func(t *testing.T) { + _, _, err := mldsa.Decode([]byte{0x00}) + require.ErrorContains(t, err, "signature code is not ML-DSA-44") + }) + + t.Run("registered with the varsig scheme registry", func(t *testing.T) { + bytes, err := mldsa.MLDSA44.Encode() + require.NoError(t, err) + + alg, n, err := varsig.AlgorithmScheme(mldsa.Code).Decode(bytes) + require.NoError(t, err) + require.Equal(t, len(bytes), n) + + _, ok := alg.(mldsa.Algorithm) + require.True(t, ok) + }) +}