From 3e3b73a9cc129af80585ab1912a276fa9a211f00 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:21:16 -0400 Subject: [PATCH] fix(api): use a 256-bit API v1 signing key The HS256 key was 22 base62 characters, about 128 bits, below the 256 bits RFC 7518 section 3.2 asks for. New keys are 32 bytes from crypto/rand, hex-encoded before being encrypted and stored. Keys already stored keep working unchanged. --- core/apiauth/signer.go | 12 ++++++++++-- core/apiauth/signer_test.go | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/core/apiauth/signer.go b/core/apiauth/signer.go index a2c21f39f..e4814d3c8 100644 --- a/core/apiauth/signer.go +++ b/core/apiauth/signer.go @@ -3,7 +3,9 @@ package apiauth import ( "cmp" "context" + "crypto/rand" "crypto/sha256" + "encoding/hex" "errors" "fmt" "strings" @@ -15,7 +17,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils" ) @@ -59,7 +60,7 @@ func loadSigner(ctx context.Context, ds model.DataStore, now func() time.Time) ( } func loadKey(ctx context.Context, ds model.DataStore) (string, error) { - enc, err := utils.Encrypt(ctx, encryptionKey(), id.NewRandom()) + enc, err := utils.Encrypt(ctx, encryptionKey(), newKey()) if err != nil { return "", fmt.Errorf("encrypting API v1 key: %w", err) } @@ -98,6 +99,13 @@ func loadKey(ctx context.Context, ds model.DataStore) (string, error) { return key, nil } +// newKey returns 256 random bits, the minimum RFC 7518 asks of an HS256 key. +func newKey() string { + b := make([]byte, 32) + _, _ = rand.Read(b) // never fails since Go 1.24 + return hex.EncodeToString(b) +} + func encryptionKey() []byte { sum := sha256.Sum256([]byte(cmp.Or(conf.Server.PasswordEncryptionKey, consts.DefaultEncryptionKey))) return sum[:] diff --git a/core/apiauth/signer_test.go b/core/apiauth/signer_test.go index acb197dde..b749d6193 100644 --- a/core/apiauth/signer_test.go +++ b/core/apiauth/signer_test.go @@ -2,12 +2,14 @@ package apiauth import ( "context" + "encoding/hex" "sync" "time" "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -23,6 +25,21 @@ var _ = Describe("signer", func() { Expect(realDS.Property().Delete(ctx, consts.JWTAPIv1SecretKey)).To(Or(Succeed(), MatchError(model.ErrNotFound))) }) + It("creates a 256-bit key", func() { + key, err := loadKey(ctx, realDS) + Expect(err).ToNot(HaveOccurred()) + raw, err := hex.DecodeString(key) + Expect(err).ToNot(HaveOccurred()) + Expect(raw).To(HaveLen(32)) + }) + + It("keeps using a stored key created in the older format", func() { + enc, err := utils.Encrypt(ctx, encryptionKey(), "legacy22charskeyABCDEF") + Expect(err).ToNot(HaveOccurred()) + Expect(realDS.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc)).To(Succeed()) + Expect(loadKey(ctx, realDS)).To(Equal("legacy22charskeyABCDEF")) + }) + It("round-trips claims", func() { s, err := loadSigner(ctx, realDS, clock) Expect(err).ToNot(HaveOccurred())