From f86731e27ad1d8cf026544d5892bf5b3ce6bdecb Mon Sep 17 00:00:00 2001 From: Francisco Delmar Kurpiel Date: Tue, 28 Apr 2026 09:37:12 +0200 Subject: [PATCH 1/2] feat: introducing support for private and public x25519 keys --- xtypes/x25519_priv.go | 136 +++++++++++++++++++++++++++++++ xtypes/x25519_pub.go | 130 +++++++++++++++++++++++++++++ xtypes/x25519_test.go | 184 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 450 insertions(+) create mode 100644 xtypes/x25519_priv.go create mode 100644 xtypes/x25519_pub.go create mode 100644 xtypes/x25519_test.go diff --git a/xtypes/x25519_priv.go b/xtypes/x25519_priv.go new file mode 100644 index 0000000..4529374 --- /dev/null +++ b/xtypes/x25519_priv.go @@ -0,0 +1,136 @@ +package xtypes + +import ( + "crypto/ecdh" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "fmt" + "sync" + + "github.com/simplesurance/proteus/internal/consts" + "github.com/simplesurance/proteus/types" +) + +// X25519PrivateKey is a xtype for *ecdh.PrivateKey. The key format is expected +// to be on PKCS8/PEM format, hex encoded (32 bytes), or raw bytes (optionally +// base64 encoded). The full value is fully redacted to avoid leaking secrets. +type X25519PrivateKey struct { + DefaultValue *ecdh.PrivateKey + UpdateFn func(*ecdh.PrivateKey) + Base64Encoder *base64.Encoding + content struct { + value *ecdh.PrivateKey + mutex sync.Mutex + } +} + +var _ types.XType = &X25519PrivateKey{} +var _ types.Redactor = &X25519PrivateKey{} + +// UnmarshalParam parses the input as a string. +func (d *X25519PrivateKey) UnmarshalParam(in *string) error { + var privK *ecdh.PrivateKey + if in != nil && *in != "" { + var err error + privK, err = parseX25519PrivateKey(*in, d.Base64Encoder) + if err != nil { + return err + } + } + + d.content.mutex.Lock() + d.content.value = privK + d.content.mutex.Unlock() + + if d.UpdateFn != nil { + d.UpdateFn(d.Value()) + } + + return nil +} + +// Value reads the current updated value, taking the default value into +// consideration. If the parameter is not marked as optional, this is +// guaranteed to be not nil. +func (d *X25519PrivateKey) Value() *ecdh.PrivateKey { + d.content.mutex.Lock() + defer d.content.mutex.Unlock() + + if d.content.value == nil { + return d.DefaultValue + } + + return d.content.value +} + +// ValueValid test if the provided parameter value is valid. Has no side +// effects. +func (d *X25519PrivateKey) ValueValid(s string) error { + if s == "" { + return types.ErrNoValue + } + _, err := parseX25519PrivateKey(s, d.Base64Encoder) + return err +} + +// GetDefaultValue will be used to read the default value when showing usage +// information. +func (d *X25519PrivateKey) GetDefaultValue() (string, error) { + return "", nil +} + +// RedactValue fully redacts the private key, to avoid leaking secrets. +func (d *X25519PrivateKey) RedactValue(string) string { + return consts.RedactedPlaceholder +} + +func parseX25519PrivateKey(v string, base64Enc *base64.Encoding) (*ecdh.PrivateKey, error) { + var data []byte + var err error + + if base64Enc != nil { + data, err = base64Enc.DecodeString(v) + if err != nil { + return nil, fmt.Errorf("not a valid base64: %w", err) + } + } else { + data = []byte(v) + } + + // Try PEM first + pemBlock, _ := pem.Decode(data) + if pemBlock != nil { + if pemBlock.Type != "PRIVATE KEY" { + return nil, fmt.Errorf("PEM of type %q is not supported. Expected: %q", + pemBlock.Type, + "PRIVATE KEY") + } + privK, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("error decoding PEM block as PKCS8: %w", err) + } + xPrivK, ok := privK.(*ecdh.PrivateKey) + if !ok || xPrivK.Curve() != ecdh.X25519() { + return nil, fmt.Errorf("expected key of type *ecdh.PrivateKey (X25519), but got type: %T", privK) + } + return xPrivK, nil + } + + // If not PEM, try hex (as seen in reference code) + if len(v) == 64 { + raw, err := hex.DecodeString(v) + if err == nil && len(raw) == 32 { + return ecdh.X25519().NewPrivateKey(raw) + } + } + + // Try raw bytes if it's 32 bytes (maybe it was base64 encoded raw bytes) + if len(data) == 32 { + return ecdh.X25519().NewPrivateKey(data) + } + + return nil, fmt.Errorf("invalid X25519 private key: expected PEM, 64-char hex, or 32-byte raw key") +} + diff --git a/xtypes/x25519_pub.go b/xtypes/x25519_pub.go new file mode 100644 index 0000000..6f70ce2 --- /dev/null +++ b/xtypes/x25519_pub.go @@ -0,0 +1,130 @@ +package xtypes + +import ( + "crypto/ecdh" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "fmt" + "sync" + + "github.com/simplesurance/proteus/types" +) + +// X25519PublicKey is a xtype for *ecdh.PublicKey. The key format is expected +// to be on PKIX/PEM format, hex encoded (32 bytes), or raw bytes (optionally +// base64 encoded). +type X25519PublicKey struct { + DefaultValue *ecdh.PublicKey + UpdateFn func(*ecdh.PublicKey) + Base64Encoder *base64.Encoding + content struct { + value *ecdh.PublicKey + mutex sync.Mutex + } +} + +var _ types.XType = &X25519PublicKey{} + +// UnmarshalParam parses the input as a string. +func (d *X25519PublicKey) UnmarshalParam(in *string) error { + var pubK *ecdh.PublicKey + if in != nil && *in != "" { + var err error + pubK, err = parseX25519PublicKey(*in, d.Base64Encoder) + if err != nil { + return err + } + } + + d.content.mutex.Lock() + d.content.value = pubK + d.content.mutex.Unlock() + + if d.UpdateFn != nil { + d.UpdateFn(d.Value()) + } + + return nil +} + +// Value reads the current updated value, taking the default value into +// consideration. If the parameter is not marked as optional, this is +// guaranteed to be not nil. +func (d *X25519PublicKey) Value() *ecdh.PublicKey { + d.content.mutex.Lock() + defer d.content.mutex.Unlock() + + if d.content.value == nil { + return d.DefaultValue + } + + return d.content.value +} + +// ValueValid test if the provided parameter value is valid. Has no side +// effects. +func (d *X25519PublicKey) ValueValid(s string) error { + if s == "" { + return types.ErrNoValue + } + _, err := parseX25519PublicKey(s, d.Base64Encoder) + return err +} + +// GetDefaultValue will be used to read the default value when showing usage +// information. +func (d *X25519PublicKey) GetDefaultValue() (string, error) { + // TODO show the public key + return "", nil +} + +func parseX25519PublicKey(v string, base64Enc *base64.Encoding) (*ecdh.PublicKey, error) { + var data []byte + var err error + + if base64Enc != nil { + data, err = base64Enc.DecodeString(v) + if err != nil { + return nil, fmt.Errorf("not a valid base64: %w", err) + } + } else { + data = []byte(v) + } + + // Try PEM first + pemBlock, _ := pem.Decode(data) + if pemBlock != nil { + if pemBlock.Type != "PUBLIC KEY" { + return nil, fmt.Errorf("PEM of type %q is not supported. Expected: %q", + pemBlock.Type, + "PUBLIC KEY") + } + pubK, err := x509.ParsePKIXPublicKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("error decoding PEM block as ANS.1 public key: %w", err) + } + xPubK, ok := pubK.(*ecdh.PublicKey) + if !ok || xPubK.Curve() != ecdh.X25519() { + return nil, fmt.Errorf("expected key of type *ecdh.PublicKey (X25519), but got type: %T", pubK) + } + return xPubK, nil + } + + // If not PEM, try hex (as seen in reference code) + if len(v) == 64 { + raw, err := hex.DecodeString(v) + if err == nil && len(raw) == 32 { + return ecdh.X25519().NewPublicKey(raw) + } + } + + // Try raw bytes if it's 32 bytes (maybe it was base64 encoded raw bytes) + if len(data) == 32 { + return ecdh.X25519().NewPublicKey(data) + } + + return nil, fmt.Errorf("invalid X25519 public key: expected PEM, 64-char hex, or 32-byte raw key") +} + diff --git a/xtypes/x25519_test.go b/xtypes/x25519_test.go new file mode 100644 index 0000000..f201ab2 --- /dev/null +++ b/xtypes/x25519_test.go @@ -0,0 +1,184 @@ +package xtypes_test + +import ( + "crypto/ecdh" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "testing" + + "github.com/simplesurance/proteus" + "github.com/simplesurance/proteus/internal/assert" + "github.com/simplesurance/proteus/sources/cfgtest" + "github.com/simplesurance/proteus/types" + "github.com/simplesurance/proteus/xtypes" +) + +func TestX25519Keys(t *testing.T) { + priv, privPEM := generateTestX25519PrivKey(t) + pub, pubPEM := generateTestX25519PubKey(t, priv) + + cfg := struct { + Priv *xtypes.X25519PrivateKey + Pub *xtypes.X25519PublicKey + }{} + + testProvider := cfgtest.New(types.ParamValues{ + "": map[string]string{ + "priv": privPEM, + "pub": pubPEM, + }, + }) + defer testProvider.Stop() + + _, err := proteus.MustParse(&cfg, proteus.WithProviders(testProvider)) + assert.NoErrorNow(t, err) + + assert.True(t, priv.Equal(cfg.Priv.Value()), "private key should match") + assert.True(t, pub.Equal(cfg.Pub.Value()), "public key should match") +} + +func TestX25519KeysHex(t *testing.T) { + priv, _ := generateTestX25519PrivKey(t) + pub := priv.PublicKey() + + privHex := hex.EncodeToString(priv.Bytes()) + pubHex := hex.EncodeToString(pub.Bytes()) + + cfg := struct { + Priv *xtypes.X25519PrivateKey + Pub *xtypes.X25519PublicKey + }{} + + testProvider := cfgtest.New(types.ParamValues{ + "": map[string]string{ + "priv": privHex, + "pub": pubHex, + }, + }) + defer testProvider.Stop() + + _, err := proteus.MustParse(&cfg, proteus.WithProviders(testProvider)) + assert.NoErrorNow(t, err) + + assert.True(t, priv.Equal(cfg.Priv.Value()), "private key should match (hex)") + assert.True(t, pub.Equal(cfg.Pub.Value()), "public key should match (hex)") +} + +func TestX25519KeysBase64Raw(t *testing.T) { + priv, _ := generateTestX25519PrivKey(t) + pub := priv.PublicKey() + + privB64 := base64.StdEncoding.EncodeToString(priv.Bytes()) + pubB64 := base64.StdEncoding.EncodeToString(pub.Bytes()) + + cfg := struct { + Priv *xtypes.X25519PrivateKey + Pub *xtypes.X25519PublicKey + }{ + Priv: &xtypes.X25519PrivateKey{Base64Encoder: base64.StdEncoding}, + Pub: &xtypes.X25519PublicKey{Base64Encoder: base64.StdEncoding}, + } + + testProvider := cfgtest.New(types.ParamValues{ + "": map[string]string{ + "priv": privB64, + "pub": pubB64, + }, + }) + defer testProvider.Stop() + + _, err := proteus.MustParse(&cfg, proteus.WithProviders(testProvider)) + assert.NoErrorNow(t, err) + + assert.True(t, priv.Equal(cfg.Priv.Value()), "private key should match (b64 raw)") + assert.True(t, pub.Equal(cfg.Pub.Value()), "public key should match (b64 raw)") +} + +func TestX25519KeysBase64PEM(t *testing.T) { + priv, privPEM := generateTestX25519PrivKey(t) + pub, pubPEM := generateTestX25519PubKey(t, priv) + + privB64 := base64.StdEncoding.EncodeToString([]byte(privPEM)) + pubB64 := base64.StdEncoding.EncodeToString([]byte(pubPEM)) + + cfg := struct { + Priv *xtypes.X25519PrivateKey + Pub *xtypes.X25519PublicKey + }{ + Priv: &xtypes.X25519PrivateKey{Base64Encoder: base64.StdEncoding}, + Pub: &xtypes.X25519PublicKey{Base64Encoder: base64.StdEncoding}, + } + + testProvider := cfgtest.New(types.ParamValues{ + "": map[string]string{ + "priv": privB64, + "pub": pubB64, + }, + }) + defer testProvider.Stop() + + _, err := proteus.MustParse(&cfg, proteus.WithProviders(testProvider)) + assert.NoErrorNow(t, err) + + assert.True(t, priv.Equal(cfg.Priv.Value()), "private key should match (b64 PEM)") + assert.True(t, pub.Equal(cfg.Pub.Value()), "public key should match (b64 PEM)") +} + +func TestX25519ValueValid(t *testing.T) { + _, privPEM := generateTestX25519PrivKey(t) + priv := &xtypes.X25519PrivateKey{} + + assert.NoError(t, priv.ValueValid(privPEM)) + assert.NoError(t, priv.ValueValid(hex.EncodeToString(make([]byte, 32)))) + assert.Error(t, priv.ValueValid("not a key")) + assert.Error(t, priv.ValueValid("")) + + _, pubPEM := generateTestX25519PubKey(t, nil) + pub := &xtypes.X25519PublicKey{} + + assert.NoError(t, pub.ValueValid(pubPEM)) + assert.NoError(t, pub.ValueValid(hex.EncodeToString(make([]byte, 32)))) + assert.Error(t, pub.ValueValid("not a key")) + assert.Error(t, pub.ValueValid("")) +} + +func generateTestX25519PrivKey(t *testing.T) (*ecdh.PrivateKey, string) { + t.Helper() + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("failed to generate X25519 private key: %v", err) + } + derBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatalf("failed to marshal X25519 private key: %v", err) + } + pemBlock := &pem.Block{ + Type: "PRIVATE KEY", + Bytes: derBytes, + } + return priv, string(pem.EncodeToMemory(pemBlock)) +} + +func generateTestX25519PubKey(t *testing.T, priv *ecdh.PrivateKey) (*ecdh.PublicKey, string) { + t.Helper() + var pub *ecdh.PublicKey + if priv != nil { + pub = priv.PublicKey() + } else { + newPriv, _ := ecdh.X25519().GenerateKey(rand.Reader) + pub = newPriv.PublicKey() + } + + derBytes, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + t.Fatalf("failed to marshal X25519 public key: %v", err) + } + pemBlock := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: derBytes, + } + return pub, string(pem.EncodeToMemory(pemBlock)) +} From 05954409a3dec5b1330e06d9ec059ef1b0ca9392 Mon Sep 17 00:00:00 2001 From: Francisco Delmar Kurpiel Date: Tue, 28 Apr 2026 09:41:05 +0200 Subject: [PATCH 2/2] Fixing linter issues --- sources/cfgenv/source.go | 2 +- xtypes/x25519_priv.go | 1 - xtypes/x25519_pub.go | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/sources/cfgenv/source.go b/sources/cfgenv/source.go index 2f68474..436d80a 100644 --- a/sources/cfgenv/source.go +++ b/sources/cfgenv/source.go @@ -86,7 +86,7 @@ func parse( } } - var violations types.ErrViolations + violations := make(types.ErrViolations, 0, len(env)) for envName := range env { violations = append(violations, types.Violation{ Message: fmt.Sprintf( diff --git a/xtypes/x25519_priv.go b/xtypes/x25519_priv.go index 4529374..7c49a1d 100644 --- a/xtypes/x25519_priv.go +++ b/xtypes/x25519_priv.go @@ -133,4 +133,3 @@ func parseX25519PrivateKey(v string, base64Enc *base64.Encoding) (*ecdh.PrivateK return nil, fmt.Errorf("invalid X25519 private key: expected PEM, 64-char hex, or 32-byte raw key") } - diff --git a/xtypes/x25519_pub.go b/xtypes/x25519_pub.go index 6f70ce2..db4e619 100644 --- a/xtypes/x25519_pub.go +++ b/xtypes/x25519_pub.go @@ -127,4 +127,3 @@ func parseX25519PublicKey(v string, base64Enc *base64.Encoding) (*ecdh.PublicKey return nil, fmt.Errorf("invalid X25519 public key: expected PEM, 64-char hex, or 32-byte raw key") } -