mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
* pin to ubuntu-24.04
* always use FIPS compatible Postgres settings
* use sha256 for remote cluster IDs
* use sha256 for client config hash
* rework S3 backend to be FIPS compatible
* skip setup-node during build, since already in container
* support FIPS builds
* Dockerfile for FIPS image, using glibc-openssl-fips
* workaround entrypoint inconsistencies
* authenticate to DockerHub
* fix FIPS_ENABLED, add test-mmctl-fips
* decouple check-mattermost-vet from test/build steps
* fixup! decouple check-mattermost-vet from test/build steps
* only build-linux-amd64 for fips
* rm entrypoint workaround
* tweak comment grammar
* rm unused Dockerfile.fips (for now)
* ignore gpg import errors, since would fail later anyway
* for fips, only make package-linux-amd64
* set FIPS_ENABLED for build step
* Add a FIPS-specific list of prepackaged plugins
Note that the names are still temporary, since they are not uploaded to
S3 yet. We may need to tweak them when that happens.
* s/golangci-lint/check-style/
This ensures we run all the `check-style` checks: previously,
`modernize` was missing.
* pin go-vet to @v2, remove annoying comment
* add -fips to linux-amd64.tz.gz package
* rm unused setup-chainctl
* use BUILD_TYPE_NAME instead
* mv fips build to enterprise-only
* fixup! use BUILD_TYPE_NAME instead
* temporarily pre-package no plugins for FIPS
* split package-cleanup
* undo package-cleanup, just skip ARM, also test
* skip arm for FIPS in second target too
* fmt Makefile
* Revert "rm unused Dockerfile.fips (for now)"
This reverts commit 601e37e0ff.
* reintroduce Dockerfile.fips and align with existing Dockerfile
* s/IMAGE/BUILD_IMAGE/
* bump the glibc-openssl-fips version
* rm redundant comment
* fix FIPS checks
* set PLUGIN_PACKAGES empty until prepackaged plugins ready
* upgrade glibc-openssl-fips, use non-dev version for final stage
* another BUILD_IMAGE case
* Prepackage the FIPS versions of plugins
* relocate FIPS_ENABLED initialization before use
* s/Config File MD5/Config File Hash/
* Update the FIPS plugin names and encode the + sign
* add /var/tmp for local socket manipulation
---------
Co-authored-by: Alejandro García Montoro <alejandro.garciamontoro@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
522 lines
14 KiB
Go
522 lines
14 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package model
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/pbkdf2"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/scrypt"
|
|
)
|
|
|
|
const (
|
|
RemoteOfflineAfterMillis = 1000 * 60 * 5 // 5 minutes
|
|
RemoteNameMinLength = 1
|
|
RemoteNameMaxLength = 64
|
|
|
|
SiteURLPending = "pending_"
|
|
SiteURLPlugin = "plugin_"
|
|
|
|
BitflagOptionAutoShareDMs Bitmask = 1 << iota // Any new DM/GM is automatically shared
|
|
BitflagOptionAutoInvited // Remote is automatically invited to all shared channels
|
|
)
|
|
|
|
var (
|
|
validRemoteNameChars = regexp.MustCompile(`^[a-zA-Z0-9\.\-\_]+$`)
|
|
|
|
ErrOfflineRemote = errors.New("remote is offline")
|
|
)
|
|
|
|
type Bitmask uint32
|
|
|
|
func (bm *Bitmask) IsBitSet(flag Bitmask) bool {
|
|
return *bm != 0
|
|
}
|
|
|
|
func (bm *Bitmask) SetBit(flag Bitmask) {
|
|
*bm |= flag
|
|
}
|
|
|
|
func (bm *Bitmask) UnsetBit(flag Bitmask) {
|
|
*bm &= ^flag
|
|
}
|
|
|
|
type RemoteCluster struct {
|
|
RemoteId string `json:"remote_id"`
|
|
RemoteTeamId string `json:"remote_team_id"` // Deprecated: this field is no longer used. It's only kept for backwards compatibility.
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
SiteURL string `json:"site_url"`
|
|
DefaultTeamId string `json:"default_team_id"`
|
|
CreateAt int64 `json:"create_at"`
|
|
DeleteAt int64 `json:"delete_at"`
|
|
LastPingAt int64 `json:"last_ping_at"`
|
|
LastGlobalUserSyncAt int64 `json:"last_global_user_sync_at"` // Timestamp of last global user sync
|
|
Token string `json:"token"`
|
|
RemoteToken string `json:"remote_token"`
|
|
Topics string `json:"topics"`
|
|
CreatorId string `json:"creator_id"`
|
|
PluginID string `json:"plugin_id"` // non-empty when sync message are to be delivered via plugin API
|
|
Options Bitmask `json:"options"` // bit-flag set of options
|
|
}
|
|
|
|
func (rc *RemoteCluster) Auditable() map[string]any {
|
|
return map[string]any{
|
|
"remote_id": rc.RemoteId,
|
|
"remote_team_id": rc.RemoteTeamId,
|
|
"name": rc.Name,
|
|
"display_name": rc.DisplayName,
|
|
"site_url": rc.SiteURL,
|
|
"default_team_id": rc.DefaultTeamId,
|
|
"create_at": rc.CreateAt,
|
|
"delete_at": rc.DeleteAt,
|
|
"last_ping_at": rc.LastPingAt,
|
|
"last_global_user_sync_at": rc.LastGlobalUserSyncAt,
|
|
"creator_id": rc.CreatorId,
|
|
"plugin_id": rc.PluginID,
|
|
"options": rc.Options,
|
|
}
|
|
}
|
|
|
|
func (rc *RemoteCluster) PreSave() {
|
|
if rc.RemoteId == "" {
|
|
if rc.PluginID != "" {
|
|
rc.RemoteId = newIDFromBytes([]byte(rc.PluginID))
|
|
} else {
|
|
rc.RemoteId = NewId()
|
|
}
|
|
}
|
|
|
|
if rc.DisplayName == "" {
|
|
rc.DisplayName = rc.Name
|
|
}
|
|
|
|
rc.Name = SanitizeUnicode(rc.Name)
|
|
rc.DisplayName = SanitizeUnicode(rc.DisplayName)
|
|
rc.Name = NormalizeRemoteName(rc.Name)
|
|
|
|
if rc.Token == "" {
|
|
rc.Token = NewId()
|
|
}
|
|
|
|
if rc.CreateAt == 0 {
|
|
rc.CreateAt = GetMillis()
|
|
}
|
|
rc.fixTopics()
|
|
}
|
|
|
|
func (rc *RemoteCluster) IsValid() *AppError {
|
|
if !IsValidId(rc.RemoteId) {
|
|
return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.id.app_error", nil, "id="+rc.RemoteId, http.StatusBadRequest)
|
|
}
|
|
|
|
if !IsValidRemoteName(rc.Name) {
|
|
return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.name.app_error", nil, "name="+rc.Name, http.StatusBadRequest)
|
|
}
|
|
|
|
if rc.CreateAt == 0 {
|
|
return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.create_at.app_error", nil, "create_at=0", http.StatusBadRequest)
|
|
}
|
|
|
|
if !IsValidId(rc.CreatorId) {
|
|
return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.id.app_error", nil, "creator_id="+rc.CreatorId, http.StatusBadRequest)
|
|
}
|
|
|
|
if rc.DefaultTeamId != "" && !IsValidId(rc.DefaultTeamId) {
|
|
return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.id.app_error", nil, "default_team_id="+rc.DefaultTeamId, http.StatusBadRequest)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (rc *RemoteCluster) Sanitize() {
|
|
rc.Token = ""
|
|
rc.RemoteToken = ""
|
|
}
|
|
|
|
type RemoteClusterPatch struct {
|
|
DisplayName *string `json:"display_name"`
|
|
DefaultTeamId *string `json:"default_team_id"`
|
|
}
|
|
|
|
func (rcp *RemoteClusterPatch) Auditable() map[string]any {
|
|
return map[string]any{
|
|
"display_name": rcp.DisplayName,
|
|
"default_team_id": rcp.DefaultTeamId,
|
|
}
|
|
}
|
|
|
|
func (rc *RemoteCluster) Patch(patch *RemoteClusterPatch) {
|
|
if patch.DisplayName != nil {
|
|
rc.DisplayName = *patch.DisplayName
|
|
}
|
|
|
|
if patch.DefaultTeamId != nil {
|
|
rc.DefaultTeamId = *patch.DefaultTeamId
|
|
}
|
|
}
|
|
|
|
type RemoteClusterWithPassword struct {
|
|
*RemoteCluster
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type RemoteClusterWithInvite struct {
|
|
RemoteCluster *RemoteCluster `json:"remote_cluster"`
|
|
Invite string `json:"invite"`
|
|
Password string `json:"password,omitempty"`
|
|
}
|
|
|
|
func newIDFromBytes(b []byte) string {
|
|
hash := sha256.New()
|
|
_, _ = hash.Write(b)
|
|
buf := hash.Sum(nil)
|
|
|
|
encoding := base32.NewEncoding("ybndrfg8ejkmcpqxot1uwisza345h769").WithPadding(base32.NoPadding)
|
|
id := encoding.EncodeToString(buf)
|
|
return id[:26]
|
|
}
|
|
|
|
func (rc *RemoteCluster) IsOptionFlagSet(flag Bitmask) bool {
|
|
return rc.Options.IsBitSet(flag)
|
|
}
|
|
|
|
func (rc *RemoteCluster) SetOptionFlag(flag Bitmask) {
|
|
rc.Options.SetBit(flag)
|
|
}
|
|
|
|
func (rc *RemoteCluster) UnsetOptionFlag(flag Bitmask) {
|
|
rc.Options.UnsetBit(flag)
|
|
}
|
|
|
|
func IsValidRemoteName(s string) bool {
|
|
if len(s) < RemoteNameMinLength || len(s) > RemoteNameMaxLength {
|
|
return false
|
|
}
|
|
return validRemoteNameChars.MatchString(s)
|
|
}
|
|
|
|
func (rc *RemoteCluster) PreUpdate() {
|
|
if rc.DisplayName == "" {
|
|
rc.DisplayName = rc.Name
|
|
}
|
|
|
|
rc.Name = SanitizeUnicode(rc.Name)
|
|
rc.DisplayName = SanitizeUnicode(rc.DisplayName)
|
|
rc.Name = NormalizeRemoteName(rc.Name)
|
|
rc.fixTopics()
|
|
}
|
|
|
|
func (rc *RemoteCluster) IsOnline() bool {
|
|
return rc.LastPingAt > GetMillis()-RemoteOfflineAfterMillis
|
|
}
|
|
|
|
func (rc *RemoteCluster) IsConfirmed() bool {
|
|
if rc.IsPlugin() {
|
|
return true // local plugins are automatically confirmed
|
|
}
|
|
|
|
if rc.SiteURL != "" && !strings.HasPrefix(rc.SiteURL, SiteURLPending) {
|
|
return true // empty or pending siteurl are not confirmed
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (rc *RemoteCluster) IsPlugin() bool {
|
|
if rc.PluginID != "" || strings.HasPrefix(rc.SiteURL, SiteURLPlugin) {
|
|
return true // local plugins are automatically confirmed
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (rc *RemoteCluster) GetSiteURL() string {
|
|
siteURL := rc.SiteURL
|
|
if strings.HasPrefix(siteURL, SiteURLPending) {
|
|
siteURL = "..."
|
|
}
|
|
if strings.HasPrefix(siteURL, SiteURLPending) || strings.HasPrefix(siteURL, SiteURLPlugin) {
|
|
siteURL = "plugin"
|
|
}
|
|
return siteURL
|
|
}
|
|
|
|
// fixTopics ensures all topics are separated by one, and only one, space.
|
|
func (rc *RemoteCluster) fixTopics() {
|
|
trimmed := strings.TrimSpace(rc.Topics)
|
|
if trimmed == "" || trimmed == "*" {
|
|
rc.Topics = trimmed
|
|
return
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(" ")
|
|
|
|
ss := strings.SplitSeq(rc.Topics, " ")
|
|
for c := range ss {
|
|
cc := strings.TrimSpace(c)
|
|
if cc != "" {
|
|
sb.WriteString(cc)
|
|
sb.WriteString(" ")
|
|
}
|
|
}
|
|
rc.Topics = sb.String()
|
|
}
|
|
|
|
func (rc *RemoteCluster) ToRemoteClusterInfo() RemoteClusterInfo {
|
|
return RemoteClusterInfo{
|
|
Name: rc.Name,
|
|
DisplayName: rc.DisplayName,
|
|
CreateAt: rc.CreateAt,
|
|
DeleteAt: rc.DeleteAt,
|
|
LastPingAt: rc.LastPingAt,
|
|
}
|
|
}
|
|
|
|
func NormalizeRemoteName(name string) string {
|
|
return strings.ToLower(name)
|
|
}
|
|
|
|
// RemoteClusterInfo provides a subset of RemoteCluster fields suitable for sending to clients.
|
|
type RemoteClusterInfo struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
CreateAt int64 `json:"create_at"`
|
|
DeleteAt int64 `json:"delete_at"`
|
|
LastPingAt int64 `json:"last_ping_at"`
|
|
}
|
|
|
|
// RemoteClusterFrame wraps a `RemoteClusterMsg` with credentials specific to a remote cluster.
|
|
type RemoteClusterFrame struct {
|
|
RemoteId string `json:"remote_id"`
|
|
Msg RemoteClusterMsg `json:"msg"`
|
|
}
|
|
|
|
func (f *RemoteClusterFrame) Auditable() map[string]any {
|
|
return map[string]any{
|
|
"remote_id": f.RemoteId,
|
|
"msg_id": f.Msg.Id,
|
|
"topic": f.Msg.Topic,
|
|
}
|
|
}
|
|
|
|
func (f *RemoteClusterFrame) IsValid() *AppError {
|
|
if !IsValidId(f.RemoteId) {
|
|
return NewAppError("RemoteClusterFrame.IsValid", "api.remote_cluster.invalid_id.app_error", nil, "RemoteId="+f.RemoteId, http.StatusBadRequest)
|
|
}
|
|
|
|
if appErr := f.Msg.IsValid(); appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoteClusterMsg represents a message that is sent and received between clusters.
|
|
// These are processed and routed via the RemoteClusters service.
|
|
type RemoteClusterMsg struct {
|
|
Id string `json:"id"`
|
|
Topic string `json:"topic"`
|
|
CreateAt int64 `json:"create_at"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
|
|
func NewRemoteClusterMsg(topic string, payload json.RawMessage) RemoteClusterMsg {
|
|
return RemoteClusterMsg{
|
|
Id: NewId(),
|
|
Topic: topic,
|
|
CreateAt: GetMillis(),
|
|
Payload: payload,
|
|
}
|
|
}
|
|
|
|
func (m RemoteClusterMsg) IsValid() *AppError {
|
|
if !IsValidId(m.Id) {
|
|
return NewAppError("RemoteClusterMsg.IsValid", "api.remote_cluster.invalid_id.app_error", nil, "Id="+m.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
if m.Topic == "" {
|
|
return NewAppError("RemoteClusterMsg.IsValid", "api.remote_cluster.invalid_topic.app_error", nil, "Topic empty", http.StatusBadRequest)
|
|
}
|
|
|
|
if len(m.Payload) == 0 {
|
|
return NewAppError("RemoteClusterMsg.IsValid", "api.context.invalid_body_param.app_error", map[string]any{"Name": "PayLoad"}, "", http.StatusBadRequest)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoteClusterPing represents a ping that is sent and received between clusters
|
|
// to indicate a connection is alive. This is the payload for a `RemoteClusterMsg`.
|
|
type RemoteClusterPing struct {
|
|
SentAt int64 `json:"sent_at"`
|
|
RecvAt int64 `json:"recv_at"`
|
|
}
|
|
|
|
// RemoteClusterInvite represents an invitation to establish a simple trust with a remote cluster.
|
|
type RemoteClusterInvite struct {
|
|
RemoteId string `json:"remote_id"`
|
|
RemoteTeamId string `json:"remote_team_id"` // Deprecated: this field is no longer used. It's only kept for backwards compatibility.
|
|
SiteURL string `json:"site_url"`
|
|
Token string `json:"token"`
|
|
RefreshedToken string `json:"refreshed_token,omitempty"` // New token generated by the remote cluster when accepting an invitation
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
func (rci *RemoteClusterInvite) IsValid() *AppError {
|
|
if !IsValidId(rci.RemoteId) {
|
|
return NewAppError("RemoteClusterInvite.IsValid", "model.remote_cluster_invite.is_valid.remote_id.app_error", nil, "id="+rci.RemoteId, http.StatusBadRequest)
|
|
}
|
|
|
|
if rci.Token == "" {
|
|
return NewAppError("RemoteClusterInvite.IsValid", "model.remote_cluster_invite.is_valid.token.app_error", nil, "Token empty", http.StatusBadRequest)
|
|
}
|
|
|
|
if _, err := url.ParseRequestURI(rci.SiteURL); err != nil {
|
|
return NewAppError("RemoteClusterInvite.IsValid", "model.remote_cluster_invite.is_valid.site_url.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (rci *RemoteClusterInvite) Encrypt(password string) ([]byte, error) {
|
|
raw, err := json.Marshal(&rci)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// create random salt to be prepended to the blob.
|
|
salt := make([]byte, 16)
|
|
if _, err = io.ReadFull(rand.Reader, salt); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var key []byte
|
|
if rci.Version >= 3 {
|
|
// Use PBKDF2 for version 3 and above
|
|
key, err = pbkdf2.Key(sha256.New, password, salt, 600000, 32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// Use scrypt for older versions
|
|
key, err = scrypt.Key([]byte(password), salt, 32768, 8, 1, 32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
block, err := aes.NewCipher(key[:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// create random nonce
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// prefix the nonce to the cyphertext so we don't need to keep track of it.
|
|
sealed := gcm.Seal(nonce, nonce, raw, nil)
|
|
|
|
return append(salt, sealed...), nil //nolint:makezero
|
|
}
|
|
|
|
func (rci *RemoteClusterInvite) Decrypt(encrypted []byte, password string) error {
|
|
if len(encrypted) <= 16 {
|
|
return errors.New("invalid length")
|
|
}
|
|
|
|
// first 16 bytes is the salt that was used to derive a key
|
|
salt := encrypted[:16]
|
|
encrypted = encrypted[16:]
|
|
|
|
// Try PBKDF2 first (for version 3+)
|
|
if err := rci.tryDecrypt(encrypted, password, salt, true); err == nil {
|
|
return nil
|
|
}
|
|
|
|
// Fall back to scrypt (for older versions)
|
|
return rci.tryDecrypt(encrypted, password, salt, false)
|
|
}
|
|
|
|
func (rci *RemoteClusterInvite) tryDecrypt(encrypted []byte, password string, salt []byte, usePBKDF2 bool) error {
|
|
var key []byte
|
|
var err error
|
|
|
|
if usePBKDF2 {
|
|
// Use PBKDF2 for version 3 and above
|
|
key, err = pbkdf2.Key(sha256.New, password, salt, 600000, 32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// Use scrypt for older versions
|
|
key, err = scrypt.Key([]byte(password), salt, 32768, 8, 1, 32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
block, err := aes.NewCipher(key[:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// nonce was prefixed to the cyphertext when encrypting so we need to extract it.
|
|
nonceSize := gcm.NonceSize()
|
|
nonce, cyphertext := encrypted[:nonceSize], encrypted[nonceSize:]
|
|
|
|
plain, err := gcm.Open(nil, nonce, cyphertext, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// try to unmarshall the decrypted JSON to this invite struct.
|
|
return json.Unmarshal(plain, &rci)
|
|
}
|
|
|
|
type RemoteClusterAcceptInvite struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
DefaultTeamId string `json:"default_team_id"`
|
|
Invite string `json:"invite"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// RemoteClusterQueryFilter provides filter criteria for RemoteClusterStore.GetAll
|
|
type RemoteClusterQueryFilter struct {
|
|
ExcludeOffline bool
|
|
InChannel string
|
|
NotInChannel string
|
|
Topic string
|
|
CreatorId string
|
|
OnlyConfirmed bool
|
|
PluginID string
|
|
OnlyPlugins bool
|
|
ExcludePlugins bool
|
|
RequireOptions Bitmask
|
|
IncludeDeleted bool
|
|
}
|