Improve license error message for wrong service environment (#37198)

* [MM-64634] Improve license error for wrong service environment

When a cryptographically valid license is uploaded to a server running a
different service environment (e.g. a production license on a test/dev
server, or vice versa), ValidateLicense now retries verification against
the alternate environment's public key. On success it returns a distinct
error that callers surface as the new WrongEnvironmentLicenseError, giving
admins an actionable message instead of the generic "Invalid license file."

Genuinely corrupt licenses still return the existing InvalidLicenseError.

Co-authored-by: mattermost-code <[email protected]>

* [MM-64634] Add tests for wrong-environment license error mapping

Cover SaveLicense (the System Console upload entry point) returning
WrongEnvironmentLicenseError for a license signed for another service
environment, and add a direct unit test for NewLicenseValidationAppError.

Co-authored-by: mattermost-code <[email protected]>

* Add audit logging for wrong-environment license upload failures

Handle WrongEnvironmentLicenseError in addLicense and localAddLicense
so environment mismatches are logged distinctly instead of falling
through to the generic unable-to-save message.

Co-authored-by: mattermost-code <[email protected]>

* Log wrong-environment audit in LicenseFromBytes error path

addLicense validates the license before SaveLicense, so
WrongEnvironmentLicenseError is returned from LicenseFromBytes and
never reaches the SaveLicense audit handler.

Co-authored-by: mattermost-code <[email protected]>

* Address PR feedback: remove license validation audit logging from addLicense

Per review feedback, the per-error-id audit logging added to the
LicenseFromBytes failure path in addLicense doesn't belong in the audit
logs. Remove it to clean up the diff; the AppError is still returned to
the caller unchanged.

Resolves: 1 change request

* Remove wrong-environment license audit logging

Per review feedback, the wrong-environment license classification does not
belong in the audit logs. Revert the audit logging additions in both the
cloud and local license upload handlers.

* Split wrong-environment license error by direction

Per review feedback, distinguish between a production license uploaded to a
test/dev server and a test/dev license uploaded to a production server. Each
case now maps to its own AppError ID and user-facing message instead of a
single generic wrong-environment error.

* Restrict wrong-environment license fallback to signature failures

Only treat alternate-key verification success as a wrong-environment
mismatch when the primary key failed with rsa.ErrVerification. Other
primary-key failures (e.g. malformed key material) are now surfaced
unchanged instead of being masked as a wrong-environment license, and
non-verification errors from the alternate-key check are no longer
discarded.

* Regenerate store layers and mocks for SessionStore

---------

Co-authored-by: mattermost-code <[email protected]>
Co-authored-by: Mattermost Build <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
cursor[bot]
2026-07-07 23:18:00 +02:00
committed by GitHub
co-authored by Hanzo Dev
parent d908576206
commit b1799e1882
7 changed files with 276 additions and 44 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ func (ps *PlatformService) LoadLicense() {
func (ps *PlatformService) SaveLicense(licenseBytes []byte) (*model.License, *model.AppError) {
licenseStr, err := utils.LicenseValidator.ValidateLicense(licenseBytes)
if err != nil {
return nil, model.NewAppError("addLicense", model.InvalidLicenseError, nil, "", http.StatusBadRequest).Wrap(err)
return nil, utils.NewLicenseValidationAppError("addLicense", err)
}
var license model.License
@@ -5,6 +5,7 @@ package platform
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
@@ -35,6 +36,21 @@ func TestSaveLicense(t *testing.T) {
require.NotNil(t, err, "shouldn't have saved license")
}
func TestSaveLicenseWrongEnvironment(t *testing.T) {
// t.Setenv prevents t.Parallel — the service environment has no config equivalent.
th := Setup(t)
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentProduction)
// validTestLicense is signed with the test key, so uploading it to a server running
// in the production service environment must report an environment mismatch rather
// than the generic invalid-license error.
_, appErr := th.Service.SaveLicense(validTestLicense)
require.NotNil(t, appErr, "shouldn't have saved a license signed for another environment")
require.Equal(t, model.WrongEnvironmentTestLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
}
func TestSaveEnterpriseAdvancedLicense(t *testing.T) {
th := Setup(t)
+87 -21
View File
@@ -11,6 +11,7 @@ import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"maps"
@@ -24,6 +25,16 @@ import (
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
// ErrLicenseProductionInTestEnvironment indicates the license signature is valid against
// the production key, but this server is running in a test or development service
// environment (i.e. a production license uploaded to a test/dev server).
var ErrLicenseProductionInTestEnvironment = errors.New("license is a production license but the server is running in a test or development service environment")
// ErrLicenseTestInProductionEnvironment indicates the license signature is valid against
// the test key, but this server is running in a production service environment (i.e. a
// test/dev license uploaded to a production server).
var ErrLicenseTestInProductionEnvironment = errors.New("license is a test or development license but the server is running in a production service environment")
var LicenseValidator LicenseValidatorIface
func init() {
@@ -43,7 +54,7 @@ type LicenseValidatorImpl struct {
func (l *LicenseValidatorImpl) LicenseFromBytes(licenseBytes []byte) (*model.License, *model.AppError) {
licenseStr, err := l.ValidateLicense(licenseBytes)
if err != nil {
return nil, model.NewAppError("LicenseFromBytes", model.InvalidLicenseError, nil, "", http.StatusBadRequest).Wrap(err)
return nil, NewLicenseValidationAppError("LicenseFromBytes", err)
}
var license model.License
@@ -74,37 +85,92 @@ func (l *LicenseValidatorImpl) ValidateLicense(signed []byte) (string, error) {
plaintext := decoded[:len(decoded)-256]
signature := decoded[len(decoded)-256:]
var publicKey []byte
switch model.GetServiceEnvironment() {
case model.ServiceEnvironmentProduction:
publicKey = productionPublicKey
case model.ServiceEnvironmentTest, model.ServiceEnvironmentDev:
publicKey = testPublicKey
}
block, _ := pem.Decode(publicKey)
if block == nil {
return "", fmt.Errorf("failed to decode public key PEM block for environment %q", model.GetServiceEnvironment())
}
public, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("Encountered error signing license: %w", err)
}
rsaPublic := public.(*rsa.PublicKey)
primaryKey, alternateKey := licenseKeysForEnvironment(model.GetServiceEnvironment())
h := sha512.New()
h.Write(plaintext)
d := h.Sum(nil)
err = rsa.VerifyPKCS1v15(rsaPublic, crypto.SHA512, d, signature)
if err != nil {
if err := verifyLicenseSignature(primaryKey, d, signature); err != nil {
// Only a genuine signature mismatch should trigger the wrong-environment
// fallback. Any other failure (e.g. malformed key material) means the
// current environment's key is broken, so surface it unchanged.
if !errors.Is(err, rsa.ErrVerification) {
return "", err
}
// The license did not verify against this environment's key. If it verifies
// against the other environment's key, the license is genuine but was signed
// for a different service environment, so report which way the mismatch goes.
if altErr := verifyLicenseSignature(alternateKey, d, signature); altErr == nil {
return "", wrongEnvironmentError(model.GetServiceEnvironment())
} else if !errors.Is(altErr, rsa.ErrVerification) {
return "", altErr
}
return "", fmt.Errorf("Invalid signature: %w", err)
}
return string(plaintext), nil
}
// wrongEnvironmentError returns the sentinel describing a license that is genuine but
// signed for the opposite service environment from the one this server runs in.
func wrongEnvironmentError(environment string) error {
if environment == model.ServiceEnvironmentProduction {
// Running production, but the license verified against the test key.
return ErrLicenseTestInProductionEnvironment
}
// Running test/dev, but the license verified against the production key.
return ErrLicenseProductionInTestEnvironment
}
// licenseKeysForEnvironment returns the public key for the given service environment
// (primary) along with the public key for the other environment (alternate).
func licenseKeysForEnvironment(environment string) (primary, alternate []byte) {
switch environment {
case model.ServiceEnvironmentProduction:
return productionPublicKey, testPublicKey
case model.ServiceEnvironmentTest, model.ServiceEnvironmentDev:
return testPublicKey, productionPublicKey
default:
return nil, nil
}
}
// verifyLicenseSignature verifies the RSA PKCS1v15 signature of a license digest against
// the provided PEM-encoded public key.
func verifyLicenseSignature(publicKey, digest, signature []byte) error {
block, _ := pem.Decode(publicKey)
if block == nil {
return fmt.Errorf("failed to decode public key PEM block")
}
public, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return fmt.Errorf("encountered error parsing public key: %w", err)
}
rsaPublic, ok := public.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("public key is not an RSA key")
}
return rsa.VerifyPKCS1v15(rsaPublic, crypto.SHA512, digest, signature)
}
// NewLicenseValidationAppError converts a license validation error returned by
// ValidateLicense into a user-facing AppError, distinguishing a license signed for the
// wrong service environment from a generally invalid license.
func NewLicenseValidationAppError(where string, err error) *model.AppError {
switch {
case errors.Is(err, ErrLicenseProductionInTestEnvironment):
return model.NewAppError(where, model.WrongEnvironmentProductionLicenseError, nil, "", http.StatusBadRequest).Wrap(err)
case errors.Is(err, ErrLicenseTestInProductionEnvironment):
return model.NewAppError(where, model.WrongEnvironmentTestLicenseError, nil, "", http.StatusBadRequest).Wrap(err)
}
return model.NewAppError(where, model.InvalidLicenseError, nil, "", http.StatusBadRequest).Wrap(err)
}
func GetAndValidateLicenseFileFromDisk(location string) (*model.License, []byte, error) {
fileName := GetLicenseFileLocation(location)
+134
View File
@@ -5,7 +5,15 @@ package utils
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"net/http"
"os"
"testing"
@@ -90,6 +98,7 @@ func TestValidateLicense(t *testing.T) {
str, err := LicenseValidator.ValidateLicense(validTestLicense)
require.Error(t, err)
require.ErrorIs(t, err, ErrLicenseTestInProductionEnvironment)
require.Empty(t, str)
})
@@ -109,6 +118,131 @@ func TestValidateLicense(t *testing.T) {
require.Empty(t, str)
require.Contains(t, err.Error(), "failed to decode public key PEM block")
})
t.Run("broken primary key is surfaced and not misreported as a wrong-environment license", func(t *testing.T) {
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentTest)
// Corrupt the primary (test) key so its verification fails for a non-signature
// reason, while standing in a generated key as the alternate (production) key
// and signing a license with it. The broken primary key must be surfaced
// rather than the license being misreported as wrong-environment.
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
originalTestKey := testPublicKey
originalProductionKey := productionPublicKey
defer func() {
testPublicKey = originalTestKey
productionPublicKey = originalProductionKey
}()
testPublicKey = []byte("not a valid PEM block")
productionPublicKey = marshalPublicKeyPEM(t, &priv.PublicKey)
signed := signLicense(t, priv, []byte(`{"id":"emulated-production-license"}`))
str, err := LicenseValidator.ValidateLicense(signed)
require.Error(t, err)
require.Empty(t, str)
require.NotErrorIs(t, err, ErrLicenseProductionInTestEnvironment)
require.NotErrorIs(t, err, ErrLicenseTestInProductionEnvironment)
require.Contains(t, err.Error(), "failed to decode public key PEM block")
})
}
func TestLicenseFromBytesEnvironmentMismatch(t *testing.T) {
t.Run("test license uploaded to a production server returns the wrong-environment error", func(t *testing.T) {
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentProduction)
license, appErr := LicenseValidator.LicenseFromBytes(validTestLicense)
require.Nil(t, license)
require.NotNil(t, appErr)
require.Equal(t, model.WrongEnvironmentTestLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("production license uploaded to a test/dev server returns the wrong-environment error", func(t *testing.T) {
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentTest)
// We cannot sign with the real production key, so stand in a generated key as
// the production key and sign a license with it to emulate a production license
// arriving at a test server.
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
originalProductionKey := productionPublicKey
defer func() { productionPublicKey = originalProductionKey }()
productionPublicKey = marshalPublicKeyPEM(t, &priv.PublicKey)
signed := signLicense(t, priv, []byte(`{"id":"emulated-production-license"}`))
license, appErr := LicenseValidator.LicenseFromBytes(signed)
require.Nil(t, license)
require.NotNil(t, appErr)
require.Equal(t, model.WrongEnvironmentProductionLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("genuinely corrupt license returns the generic invalid error", func(t *testing.T) {
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentTest)
// A blob long enough to pass the length check but signed by no known key.
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
corrupt := signLicense(t, priv, []byte(`{"id":"corrupt-license"}`))
license, appErr := LicenseValidator.LicenseFromBytes(corrupt)
require.Nil(t, license)
require.NotNil(t, appErr)
require.Equal(t, model.InvalidLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
}
func TestNewLicenseValidationAppError(t *testing.T) {
t.Run("maps the production-in-test sentinel to WrongEnvironmentProductionLicenseError", func(t *testing.T) {
appErr := NewLicenseValidationAppError("Test", fmt.Errorf("wrap: %w", ErrLicenseProductionInTestEnvironment))
require.Equal(t, model.WrongEnvironmentProductionLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
require.ErrorIs(t, appErr, ErrLicenseProductionInTestEnvironment)
})
t.Run("maps the test-in-production sentinel to WrongEnvironmentTestLicenseError", func(t *testing.T) {
appErr := NewLicenseValidationAppError("Test", fmt.Errorf("wrap: %w", ErrLicenseTestInProductionEnvironment))
require.Equal(t, model.WrongEnvironmentTestLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
require.ErrorIs(t, appErr, ErrLicenseTestInProductionEnvironment)
})
t.Run("maps any other error to InvalidLicenseError", func(t *testing.T) {
appErr := NewLicenseValidationAppError("Test", fmt.Errorf("Invalid signature"))
require.Equal(t, model.InvalidLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
require.NotErrorIs(t, appErr, ErrLicenseProductionInTestEnvironment)
require.NotErrorIs(t, appErr, ErrLicenseTestInProductionEnvironment)
})
}
func signLicense(t *testing.T, priv *rsa.PrivateKey, payload []byte) []byte {
t.Helper()
h := sha512.New()
h.Write(payload)
digest := h.Sum(nil)
signature, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA512, digest)
require.NoError(t, err)
raw := append(append([]byte{}, payload...), signature...)
return []byte(base64.StdEncoding.EncodeToString(raw))
}
func marshalPublicKeyPEM(t *testing.T, pub *rsa.PublicKey) []byte {
t.Helper()
der, err := x509.MarshalPKIXPublicKey(pub)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})
}
func TestGetLicenseFileLocation(t *testing.T) {
+8
View File
@@ -2680,6 +2680,14 @@
"id": "api.license.add_license.unique_users.app_error",
"translation": "This license only supports {{.Users}} users, when your system has {{.Count}} unique users. Unique users are counted distinctly by email address. You can see total user count under Site Reports -> View Statistics."
},
{
"id": "api.license.add_license.wrong_environment_production.app_error",
"translation": "This is a production license, but this server is running in a test or development environment. Production licenses can only be used on production servers."
},
{
"id": "api.license.add_license.wrong_environment_test.app_error",
"translation": "This is a test or development license, but this server is running in a production environment. Test or development licenses can only be used on test or development servers."
},
{
"id": "api.license.client.old_format.app_error",
"translation": "New format for the client license is not supported yet. Please specify format=old in the query string."
+8 -2
View File
@@ -16,8 +16,14 @@ const (
ExpiredLicenseError = "api.license.add_license.expired.app_error"
InvalidLicenseError = "api.license.add_license.invalid.app_error"
LicenseGracePeriod = DayInMilliseconds * 10 //10 days
LicenseRenewalLink = "https://mattermost.com/renew/"
// WrongEnvironmentProductionLicenseError is returned when a production license is
// uploaded to a server running in a test or development service environment.
WrongEnvironmentProductionLicenseError = "api.license.add_license.wrong_environment_production.app_error"
// WrongEnvironmentTestLicenseError is returned when a test or development license is
// uploaded to a server running in a production service environment.
WrongEnvironmentTestLicenseError = "api.license.add_license.wrong_environment_test.app_error"
LicenseGracePeriod = DayInMilliseconds * 10 //10 days
LicenseRenewalLink = "https://mattermost.com/renew/"
LicenseShortSkuE10 = "E10"
LicenseShortSkuE20 = "E20"
+22 -20
View File
@@ -467,26 +467,28 @@ func extractByFuncName(name string, args []ast.Expr) *string {
func extractForConstants(name string, valueNode ast.Expr) *string {
validConstants := map[string]bool{
"MISSING_CHANNEL_ERROR": true,
"MISSING_CHANNEL_MEMBER_ERROR": true,
"CHANNEL_EXISTS_ERROR": true,
"MISSING_STATUS_ERROR": true,
"TEAM_MEMBER_EXISTS_ERROR": true,
"MISSING_AUTH_ACCOUNT_ERROR": true,
"MISSING_ACCOUNT_ERROR": true,
"EXPIRED_LICENSE_ERROR": true,
"INVALID_LICENSE_ERROR": true,
"MissingChannelError": true,
"MissingChannelMemberError": true,
"ChannelExistsError": true,
"MissingStatusError": true,
"TeamMemberExistsError": true,
"MissingAuthAccountError": true,
"MissingAccountError": true,
"ExpiredLicenseError": true,
"InvalidLicenseError": true,
"NoTranslation": true,
"PayloadParseError": true,
"MISSING_CHANNEL_ERROR": true,
"MISSING_CHANNEL_MEMBER_ERROR": true,
"CHANNEL_EXISTS_ERROR": true,
"MISSING_STATUS_ERROR": true,
"TEAM_MEMBER_EXISTS_ERROR": true,
"MISSING_AUTH_ACCOUNT_ERROR": true,
"MISSING_ACCOUNT_ERROR": true,
"EXPIRED_LICENSE_ERROR": true,
"INVALID_LICENSE_ERROR": true,
"MissingChannelError": true,
"MissingChannelMemberError": true,
"ChannelExistsError": true,
"MissingStatusError": true,
"TeamMemberExistsError": true,
"MissingAuthAccountError": true,
"MissingAccountError": true,
"ExpiredLicenseError": true,
"InvalidLicenseError": true,
"WrongEnvironmentProductionLicenseError": true,
"WrongEnvironmentTestLicenseError": true,
"NoTranslation": true,
"PayloadParseError": true,
}
if _, ok := validConstants[name]; !ok {