Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
160 changes: 160 additions & 0 deletions multikey/mldsa44/signer.go
Original file line number Diff line number Diff line change
@@ -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()
}
118 changes: 118 additions & 0 deletions multikey/mldsa44/signer_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading