From 10b93eddedd05ab105e84a8272955cc03ad5ae85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Duffeck?= Date: Wed, 7 Oct 2026 08:46:53 +0200 Subject: [PATCH 1/2] Derive the guest session key from OC_JWT_SECRET Replace AUTH_GUEST_SESSION_JWT_SECRET with a key derived via HMAC-SHA256 from the reva JWT secret. Guest session tokens and reva access tokens remain unforgeable across each other without a second secret to configure. --- opencloud/pkg/init/init.go | 13 ---------- opencloud/pkg/init/structs.go | 6 ----- services/auth-guest/README.md | 5 ++-- services/auth-guest/pkg/command/server.go | 2 +- services/auth-guest/pkg/config/config.go | 24 +++++++++++++++++- services/auth-guest/pkg/config/config_test.go | 25 +++++++++++++++++++ .../auth-guest/pkg/config/parser/parse.go | 14 ----------- .../pkg/config/parser/parse_test.go | 14 ++++------- services/auth-guest/pkg/revaconfig/config.go | 2 +- 9 files changed, 58 insertions(+), 47 deletions(-) create mode 100644 services/auth-guest/pkg/config/config_test.go diff --git a/opencloud/pkg/init/init.go b/opencloud/pkg/init/init.go index 07380b004a..a18ebf3a6c 100644 --- a/opencloud/pkg/init/init.go +++ b/opencloud/pkg/init/init.go @@ -69,7 +69,6 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword idmServicePassword, idpServicePassword, ocAdminServicePassword, revaServicePassword string tokenManagerJwtSecret, collaborationWOPISecret, machineAuthAPIKey, systemUserAPIKey string revaTransferSecret, thumbnailsTransferSecret, serviceAccountSecret, urlSigningSecret string - authGuestJWTSecret string adminPasswdwordGenerated bool ) @@ -104,13 +103,6 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword return fmt.Errorf("could not generate random secret for urlSigningSecret: %s", err) } } - authGuestJWTSecret = oldCfg.AuthGuest.JWT.Secret - if authGuestJWTSecret == "" { - authGuestJWTSecret, err = generators.GenerateRandomPassword(passwordLength) - if err != nil { - return fmt.Errorf("could not generate random secret for authGuestJWTSecret: %s", err) - } - } } else { systemUserID = uuid.NewString() adminUserID = uuid.NewString() @@ -163,10 +155,6 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword if err != nil { return fmt.Errorf("could not generate random secret for urlSigningSecret: %s", err) } - authGuestJWTSecret, err = generators.GenerateRandomPassword(passwordLength) - if err != nil { - return fmt.Errorf("could not generate random secret for authGuestJWTSecret: %s", err) - } thumbnailsTransferSecret, err = generators.GenerateRandomPassword(passwordLength) if err != nil { return fmt.Errorf("could not generate random password for thumbnailsTransferSecret: %s", err) @@ -226,7 +214,6 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword }, AuthGuest: AuthGuest{ ServiceAccount: serviceAccount, - JWT: AuthGuestJWT{Secret: authGuestJWTSecret}, }, Users: UsersAndGroupsService{ Drivers: LdapBasedService{ diff --git a/opencloud/pkg/init/structs.go b/opencloud/pkg/init/structs.go index b0f63d84f2..2833dc0597 100644 --- a/opencloud/pkg/init/structs.go +++ b/opencloud/pkg/init/structs.go @@ -57,12 +57,6 @@ type Activitylog struct { // AuthGuest is the configuration for the auth-guest service type AuthGuest struct { ServiceAccount ServiceAccount `yaml:"service_account"` - JWT AuthGuestJWT `yaml:"jwt"` -} - -// AuthGuestJWT is the configuration for the guest session tokens -type AuthGuestJWT struct { - Secret string `yaml:"secret"` } // App is the configuration for the collaboration service diff --git a/services/auth-guest/README.md b/services/auth-guest/README.md index ea6c7326ff..ca95611990 100644 --- a/services/auth-guest/README.md +++ b/services/auth-guest/README.md @@ -91,10 +91,11 @@ the event consumer, set `AUTH_GUEST_HTTP_DISABLED=true`. Relevant options: -- `AUTH_GUEST_SESSION_JWT_SECRET` — secret used to sign guest session tokens. - It must differ from `OC_JWT_SECRET`. - `AUTH_GUEST_JWT_COOKIE_NAME`, `AUTH_GUEST_JWT_TTL` — session cookie name and lifetime. +- Guest session tokens are signed with a key derived from `OC_JWT_SECRET`. There + is no separate secret to configure; rotating `OC_JWT_SECRET` invalidates all + guest sessions. - `AUTH_GUEST_TOKENS_STORAGE_ROOT` — where guest link token records are stored. - `AUTH_GUEST_SERVICE_ACCOUNT_ID`, `AUTH_GUEST_SERVICE_ACCOUNT_SECRET` — service account used to query the gateway for share metadata. diff --git a/services/auth-guest/pkg/command/server.go b/services/auth-guest/pkg/command/server.go index 9139031b79..18ffc4f0e5 100644 --- a/services/auth-guest/pkg/command/server.go +++ b/services/auth-guest/pkg/command/server.go @@ -79,7 +79,7 @@ func Server(cfg *config.Config) *cobra.Command { tokenSvc := token.NewTokenService() store := storage.NewFileManager(cfg.Storage.RootDirectory) - jwtService := jwt.NewJwtService(cfg.JWT.Secret, cfg.JWT.TTL) + jwtService := jwt.NewJwtService(cfg.SessionSecret(), cfg.JWT.TTL) authGuest := authguest.NewAuthGuestService(tokenSvc, store, authguest.GatewaySelector(gatewaySelector), diff --git a/services/auth-guest/pkg/config/config.go b/services/auth-guest/pkg/config/config.go index 9d3292ad84..f2832bab14 100644 --- a/services/auth-guest/pkg/config/config.go +++ b/services/auth-guest/pkg/config/config.go @@ -5,6 +5,9 @@ package config import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "time" "github.com/opencloud-eu/opencloud/pkg/shared" @@ -94,7 +97,26 @@ type TokenManager struct { // JWT defines the configuration for guest session tokens. type JWT struct { - Secret string `yaml:"secret" env:"AUTH_GUEST_SESSION_JWT_SECRET" desc:"The secret used to sign and validate guest session tokens. It must differ from OC_JWT_SECRET." introductionVersion:"%%NEXT%%" mask:"password"` CookieName string `yaml:"cookie_name" env:"AUTH_GUEST_JWT_COOKIE_NAME" desc:"The name of the session cookie set when a guest token is redeemed." introductionVersion:"%%NEXT%%"` TTL time.Duration `yaml:"ttl" env:"AUTH_GUEST_JWT_TTL" desc:"The lifetime of a redeemed guest session token." introductionVersion:"%%NEXT%%"` } + +// sessionSecretLabel binds the derived guest session key to its purpose. Changing it +// invalidates all existing guest sessions. +const sessionSecretLabel = "opencloud auth-guest session jwt v1" + +// SessionSecret returns the key used to sign and validate guest session tokens. +// The guest session token and the reva access token are both HS256 JWTs, so they must +// not share a key, otherwise they would be interchangeable. The key is derived from the +// reva JWT secret to keep them apart without requiring an additional secret. +// +// An empty reva secret yields an empty session key rather than a key anyone could +// compute from the label alone. The guestlinks auth manager refuses an empty key. +func (c *Config) SessionSecret() string { + if c.TokenManager == nil || c.TokenManager.JWTSecret == "" { + return "" + } + mac := hmac.New(sha256.New, []byte(c.TokenManager.JWTSecret)) + mac.Write([]byte(sessionSecretLabel)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/services/auth-guest/pkg/config/config_test.go b/services/auth-guest/pkg/config/config_test.go new file mode 100644 index 0000000000..eeb81a7dff --- /dev/null +++ b/services/auth-guest/pkg/config/config_test.go @@ -0,0 +1,25 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSessionSecret(t *testing.T) { + cfg := &Config{TokenManager: &TokenManager{JWTSecret: "reva-secret"}} + other := &Config{TokenManager: &TokenManager{JWTSecret: "other-secret"}} + + assert.Len(t, cfg.SessionSecret(), 64) + assert.Equal(t, cfg.SessionSecret(), cfg.SessionSecret(), "derivation must be deterministic") + assert.NotEqual(t, cfg.TokenManager.JWTSecret, cfg.SessionSecret()) + assert.NotEqual(t, cfg.SessionSecret(), other.SessionSecret()) +} + +func TestSessionSecretMissingJWTSecret(t *testing.T) { + assert.Empty(t, (&Config{}).SessionSecret(), "nil token manager") + assert.Empty(t, (&Config{TokenManager: &TokenManager{}}).SessionSecret(), "empty jwt secret") +} diff --git a/services/auth-guest/pkg/config/parser/parse.go b/services/auth-guest/pkg/config/parser/parse.go index 38b2d27192..b2add76af3 100644 --- a/services/auth-guest/pkg/config/parser/parse.go +++ b/services/auth-guest/pkg/config/parser/parse.go @@ -5,10 +5,8 @@ package parser import ( "errors" - "fmt" occfg "github.com/opencloud-eu/opencloud/pkg/config" - ocdefaults "github.com/opencloud-eu/opencloud/pkg/config/defaults" "github.com/opencloud-eu/opencloud/pkg/shared" "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config/defaults" @@ -43,17 +41,5 @@ func Validate(cfg *config.Config) error { if cfg.TokenManager == nil || cfg.TokenManager.JWTSecret == "" { return shared.MissingJWTTokenError(cfg.Service.Name) } - if cfg.JWT.Secret == "" { - return fmt.Errorf("the guest session secret has not been set properly in your config for %s. "+ - "Make sure your %s config contains the proper values "+ - "(e.g. by using 'opencloud init --diff' and applying the patch or setting a value manually in "+ - "the config/corresponding environment variable AUTH_GUEST_SESSION_JWT_SECRET)", - cfg.Service.Name, ocdefaults.BaseConfigPath()) - } - // The guest session token and the reva access token are both HS256 JWTs. Signing them - // with the same key would make them interchangeable. - if cfg.JWT.Secret == cfg.TokenManager.JWTSecret { - return fmt.Errorf("the guest session secret (AUTH_GUEST_SESSION_JWT_SECRET) of %s must differ from the jwt secret (OC_JWT_SECRET)", cfg.Service.Name) - } return nil } diff --git a/services/auth-guest/pkg/config/parser/parse_test.go b/services/auth-guest/pkg/config/parser/parse_test.go index 1852027318..0514cb7aa0 100644 --- a/services/auth-guest/pkg/config/parser/parse_test.go +++ b/services/auth-guest/pkg/config/parser/parse_test.go @@ -11,22 +11,18 @@ import ( func TestValidate(t *testing.T) { tests := []struct { - name string - jwtSecret string - sessionSecret string - wantErr bool + name string + jwtSecret string + wantErr bool }{ - {name: "distinct secrets", jwtSecret: "reva-secret", sessionSecret: "session-secret"}, - {name: "missing jwt secret", sessionSecret: "session-secret", wantErr: true}, - {name: "missing session secret", jwtSecret: "reva-secret", wantErr: true}, - {name: "shared secret", jwtSecret: "same-secret", sessionSecret: "same-secret", wantErr: true}, + {name: "jwt secret set", jwtSecret: "reva-secret"}, + {name: "missing jwt secret", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &config.Config{ TokenManager: &config.TokenManager{JWTSecret: tt.jwtSecret}, - JWT: config.JWT{Secret: tt.sessionSecret}, } err := Validate(cfg) diff --git a/services/auth-guest/pkg/revaconfig/config.go b/services/auth-guest/pkg/revaconfig/config.go index 4cb0ae745b..f8548a212f 100644 --- a/services/auth-guest/pkg/revaconfig/config.go +++ b/services/auth-guest/pkg/revaconfig/config.go @@ -27,7 +27,7 @@ func GuestLinksConfigFromStruct(cfg *config.Config) map[string]any { "auth_managers": map[string]any{ "guestlinks": map[string]any{ "gateway_addr": cfg.RevaGateway, - "jwt_secret": cfg.JWT.Secret, + "jwt_secret": cfg.SessionSecret(), "service_account_id": cfg.ServiceAccount.ServiceAccountID, "service_account_secret": cfg.ServiceAccount.ServiceAccountSecret, }, From e4afd03bd047ba0ae8f5ff2c3de551495f2c6a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Duffeck?= Date: Wed, 7 Oct 2026 10:04:17 +0200 Subject: [PATCH 2/2] Use HKDF instead of plain hmac to derive the session cookie secret --- services/auth-guest/pkg/config/config.go | 26 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/services/auth-guest/pkg/config/config.go b/services/auth-guest/pkg/config/config.go index f2832bab14..23af285b91 100644 --- a/services/auth-guest/pkg/config/config.go +++ b/services/auth-guest/pkg/config/config.go @@ -5,7 +5,7 @@ package config import ( "context" - "crypto/hmac" + "crypto/hkdf" "crypto/sha256" "encoding/hex" "time" @@ -101,22 +101,30 @@ type JWT struct { TTL time.Duration `yaml:"ttl" env:"AUTH_GUEST_JWT_TTL" desc:"The lifetime of a redeemed guest session token." introductionVersion:"%%NEXT%%"` } -// sessionSecretLabel binds the derived guest session key to its purpose. Changing it -// invalidates all existing guest sessions. -const sessionSecretLabel = "opencloud auth-guest session jwt v1" +const ( + // sessionSecretInfo binds the derived guest session key to its purpose. Changing it + // invalidates all existing guest sessions. + sessionSecretInfo = "opencloud auth-guest session jwt v1" + // sessionSecretLength is the length of the derived key in bytes. + sessionSecretLength = 32 +) // SessionSecret returns the key used to sign and validate guest session tokens. // The guest session token and the reva access token are both HS256 JWTs, so they must // not share a key, otherwise they would be interchangeable. The key is derived from the -// reva JWT secret to keep them apart without requiring an additional secret. +// reva JWT secret with HKDF-SHA256 to keep them apart without requiring an additional +// secret. // // An empty reva secret yields an empty session key rather than a key anyone could -// compute from the label alone. The guestlinks auth manager refuses an empty key. +// compute from the info string alone. The guestlinks auth manager refuses an empty key. func (c *Config) SessionSecret() string { if c.TokenManager == nil || c.TokenManager.JWTSecret == "" { return "" } - mac := hmac.New(sha256.New, []byte(c.TokenManager.JWTSecret)) - mac.Write([]byte(sessionSecretLabel)) - return hex.EncodeToString(mac.Sum(nil)) + key, err := hkdf.Key(sha256.New, []byte(c.TokenManager.JWTSecret), nil, sessionSecretInfo, sessionSecretLength) + if err != nil { + // Only possible for an invalid key length, which is a constant here. + return "" + } + return hex.EncodeToString(key) }