Files
Assistant/platform/secrets/machine_credentials.go
2026-09-01 23:13:15 +03:00

405 lines
14 KiB
Go

package secrets
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"time"
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
)
const (
ControlPlaneServiceTokenID = "control_plane_service_token"
AcceptanceHarnessServiceTokenID = "acceptance_harness_service_token"
MachineCredentialOwner = "platform.secrets.local-control-plane"
)
var (
ErrUnsupportedMachineCredential = errors.New("unsupported machine credential")
ErrCredentialArtifactUnsafe = errors.New("machine credential artifact is unsafe")
)
// MachineCredentialKind describes technical credential material. It conveys no
// authority and no secret value.
type MachineCredentialKind string
const (
MachineCredentialServiceToken MachineCredentialKind = "SERVICE_TO_SERVICE_TOKEN"
MachineCredentialDatabaseRole MachineCredentialKind = "DATABASE_ROLE_PASSWORD"
MachineCredentialInternalAPI MachineCredentialKind = "INTERNAL_API_CREDENTIAL"
)
type MachineCredentialStatus string
const (
MachineCredentialActive MachineCredentialStatus = "ACTIVE"
MachineCredentialRotating MachineCredentialStatus = "ROTATING"
MachineCredentialCompromised MachineCredentialStatus = "COMPROMISED"
MachineCredentialRevoked MachineCredentialStatus = "REVOKED"
)
type MachineCredentialSpec struct {
ID string
Kind MachineCredentialKind
Purpose secretcontracts.SecretPurpose
Consumers []secretcontracts.RuntimeConsumer
Bytes int
}
type MachineCredentialMetadata struct {
SecretID string `json:"secret_id"`
Kind MachineCredentialKind `json:"kind"`
Purpose secretcontracts.SecretPurpose `json:"purpose"`
Owner string `json:"owner"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
RotatedAt time.Time `json:"rotated_at,omitempty"`
Consumers []secretcontracts.RuntimeConsumer `json:"consumers"`
Status MachineCredentialStatus `json:"status"`
Algorithm string `json:"algorithm"`
GenerationMethod string `json:"generation_method"`
DataPolicy MachineCredentialDataPolicy `json:"data_policy"`
}
// MachineCredentialDataPolicy is explicit technical handling policy. It is
// metadata only; it never contains or resolves a credential value.
type MachineCredentialDataPolicy struct {
Indexable bool `json:"indexable"`
Embeddable bool `json:"embeddable"`
Retrievable bool `json:"retrievable"`
ModelVisible bool `json:"model_visible"`
}
type MachineCredentialAudit struct {
SecretID string `json:"secret_id"`
Version int `json:"version"`
Action string `json:"action"`
Reason string `json:"reason,omitempty"`
At time.Time `json:"at"`
}
type MachineCredentialResult struct {
Metadata MachineCredentialMetadata
Created bool
Rotated bool
}
// LocalMachineCredentialStore is the one writer for a dedicated protected
// volume. The value and metadata always have separate files.
type LocalMachineCredentialStore struct {
root string
metadata string
runtimeUID int
runtimeGID int
now func() time.Time
random io.Reader
}
func NewLocalMachineCredentialStore(root string, runtimeUID, runtimeGID int, now func() time.Time) (*LocalMachineCredentialStore, error) {
root = strings.TrimSpace(root)
if root == "" {
return nil, fmt.Errorf("machine credential root: %w", ErrPathOutsideRoot)
}
clean, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return nil, fmt.Errorf("machine credential root: %w", err)
}
if now == nil {
now = time.Now
}
return &LocalMachineCredentialStore{root: clean, metadata: filepath.Join(clean, ".metadata"), runtimeUID: runtimeUID, runtimeGID: runtimeGID, now: now, random: rand.Reader}, nil
}
func ControlPlaneServiceTokenSpec() MachineCredentialSpec {
return MachineCredentialSpec{
ID: ControlPlaneServiceTokenID,
Kind: MachineCredentialServiceToken,
Purpose: secretcontracts.SecretPurposeServiceAuthentication,
Consumers: []secretcontracts.RuntimeConsumer{secretcontracts.RuntimeConsumerCore, secretcontracts.RuntimeConsumerCommit},
Bytes: 48,
}
}
func AcceptanceHarnessServiceTokenSpec() MachineCredentialSpec {
return MachineCredentialSpec{ID: AcceptanceHarnessServiceTokenID, Kind: MachineCredentialServiceToken, Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumers: []secretcontracts.RuntimeConsumer{secretcontracts.RuntimeConsumerCore}, Bytes: 48}
}
func (s *LocalMachineCredentialStore) Ensure(spec MachineCredentialSpec) (MachineCredentialResult, error) {
if err := validateMachineCredentialSpec(spec); err != nil {
return MachineCredentialResult{}, err
}
if err := s.prepareDirectories(); err != nil {
return MachineCredentialResult{}, err
}
if info, err := os.Lstat(s.artifactPath(spec)); err == nil {
if !safeArtifact(info) {
return MachineCredentialResult{}, ErrCredentialArtifactUnsafe
}
metadata, err := s.ensureMetadata(spec, false, "")
return MachineCredentialResult{Metadata: metadata}, err
} else if !errors.Is(err, os.ErrNotExist) {
return MachineCredentialResult{}, err
}
metadata, err := s.createVersion(spec, 1, "CREATED", "")
if err != nil {
return MachineCredentialResult{}, err
}
return MachineCredentialResult{Metadata: metadata, Created: true}, nil
}
func (s *LocalMachineCredentialStore) Rotate(spec MachineCredentialSpec, reason string) (MachineCredentialResult, error) {
if err := validateMachineCredentialSpec(spec); err != nil {
return MachineCredentialResult{}, err
}
if err := s.prepareDirectories(); err != nil {
return MachineCredentialResult{}, err
}
current, err := s.readMetadata(spec)
if err != nil {
return MachineCredentialResult{}, err
}
if current.Status != MachineCredentialActive {
return MachineCredentialResult{}, fmt.Errorf("machine credential status %s cannot rotate", current.Status)
}
rotating := current
rotating.Status = MachineCredentialRotating
if err := s.writeMetadata(spec, rotating); err != nil {
return MachineCredentialResult{}, err
}
if err := s.appendAudit(MachineCredentialAudit{SecretID: spec.ID, Version: current.Version, Action: "ROTATION_STARTED", Reason: strings.TrimSpace(reason), At: s.now().UTC()}); err != nil {
_ = s.writeMetadata(spec, current)
return MachineCredentialResult{}, err
}
metadata, err := s.createVersion(spec, current.Version+1, "ROTATED", strings.TrimSpace(reason))
if err != nil {
_ = s.writeMetadata(spec, current)
_ = s.appendAudit(MachineCredentialAudit{SecretID: spec.ID, Version: current.Version, Action: "ROTATION_FAILED", At: s.now().UTC()})
return MachineCredentialResult{}, err
}
return MachineCredentialResult{Metadata: metadata, Rotated: true}, nil
}
func (s *LocalMachineCredentialStore) createVersion(spec MachineCredentialSpec, version int, action, reason string) (MachineCredentialMetadata, error) {
value, err := randomCredential(s.random, spec.Bytes)
if err != nil {
return MachineCredentialMetadata{}, err
}
if err := s.atomicArtifact(spec, value); err != nil {
return MachineCredentialMetadata{}, err
}
now := s.now().UTC()
metadata := MachineCredentialMetadata{SecretID: spec.ID, Kind: spec.Kind, Purpose: spec.Purpose, Owner: MachineCredentialOwner, Version: version, CreatedAt: now, Consumers: append([]secretcontracts.RuntimeConsumer(nil), spec.Consumers...), Status: MachineCredentialActive, Algorithm: "CSPRNG", GenerationMethod: "crypto/rand + base64url", DataPolicy: MachineCredentialDataPolicy{Indexable: false, Embeddable: false, Retrievable: false, ModelVisible: false}}
if version > 1 {
metadata.RotatedAt = now
}
if err := s.writeMetadata(spec, metadata); err != nil {
return MachineCredentialMetadata{}, err
}
if err := s.appendAudit(MachineCredentialAudit{SecretID: spec.ID, Version: version, Action: action, Reason: reason, At: now}); err != nil {
return MachineCredentialMetadata{}, err
}
return metadata, nil
}
func (s *LocalMachineCredentialStore) ensureMetadata(spec MachineCredentialSpec, rotated bool, reason string) (MachineCredentialMetadata, error) {
metadata, err := s.readMetadata(spec)
if err == nil {
return metadata, nil
}
if !errors.Is(err, os.ErrNotExist) {
return MachineCredentialMetadata{}, err
}
now := s.now().UTC()
metadata = MachineCredentialMetadata{SecretID: spec.ID, Kind: spec.Kind, Purpose: spec.Purpose, Owner: MachineCredentialOwner, Version: 1, CreatedAt: now, Consumers: append([]secretcontracts.RuntimeConsumer(nil), spec.Consumers...), Status: MachineCredentialActive, Algorithm: "CSPRNG", GenerationMethod: "preexisting protected artifact", DataPolicy: MachineCredentialDataPolicy{Indexable: false, Embeddable: false, Retrievable: false, ModelVisible: false}}
if rotated {
metadata.RotatedAt = now
}
if err := s.writeMetadata(spec, metadata); err != nil {
return MachineCredentialMetadata{}, err
}
if err := s.appendAudit(MachineCredentialAudit{SecretID: spec.ID, Version: metadata.Version, Action: "ADOPTED", Reason: reason, At: now}); err != nil {
return MachineCredentialMetadata{}, err
}
return metadata, nil
}
func (s *LocalMachineCredentialStore) prepareDirectories() error {
if err := os.MkdirAll(s.root, 0o710); err != nil {
return err
}
if err := os.Chmod(s.root, 0o710); err != nil {
return err
}
if s.runtimeGID >= 0 && os.Geteuid() == 0 {
if err := os.Chown(s.root, 0, s.runtimeGID); err != nil {
return err
}
}
if err := os.MkdirAll(s.metadata, 0o700); err != nil {
return err
}
return os.Chmod(s.metadata, 0o700)
}
func (s *LocalMachineCredentialStore) atomicArtifact(spec MachineCredentialSpec, value string) error {
temp, err := os.CreateTemp(s.root, ".credential-*")
if err != nil {
return err
}
tempPath := temp.Name()
defer os.Remove(tempPath)
if err := temp.Chmod(0o400); err != nil {
temp.Close()
return err
}
if s.runtimeUID >= 0 && s.runtimeGID >= 0 && os.Geteuid() == 0 {
if err := temp.Chown(s.runtimeUID, s.runtimeGID); err != nil {
temp.Close()
return err
}
}
if _, err := temp.WriteString(value + "\n"); err != nil {
temp.Close()
return err
}
if err := temp.Sync(); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
target := s.artifactPath(spec)
// Windows cannot replace a read-only file. On Linux the owning runtime can
// still read this temporary mode; the final replacement remains atomic.
if _, err := os.Lstat(target); err == nil {
if err := os.Chmod(target, 0o600); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return os.Rename(tempPath, target)
}
func (s *LocalMachineCredentialStore) readMetadata(spec MachineCredentialSpec) (MachineCredentialMetadata, error) {
contents, err := os.ReadFile(s.metadataPath(spec))
if err != nil {
return MachineCredentialMetadata{}, err
}
var metadata MachineCredentialMetadata
if err := json.Unmarshal(contents, &metadata); err != nil {
return MachineCredentialMetadata{}, err
}
if metadata.SecretID != spec.ID || metadata.Version < 1 || metadata.Purpose != spec.Purpose || metadata.Owner != MachineCredentialOwner {
return MachineCredentialMetadata{}, ErrCredentialArtifactUnsafe
}
return metadata, nil
}
func (s *LocalMachineCredentialStore) writeMetadata(spec MachineCredentialSpec, metadata MachineCredentialMetadata) error {
contents, err := json.Marshal(metadata)
if err != nil {
return err
}
return atomicPrivateFile(s.metadataPath(spec), contents)
}
func (s *LocalMachineCredentialStore) appendAudit(audit MachineCredentialAudit) error {
contents, err := json.Marshal(audit)
if err != nil {
return err
}
file, err := os.OpenFile(filepath.Join(s.metadata, audit.SecretID+".audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
if _, err := file.Write(append(contents, '\n')); err != nil {
return err
}
return file.Sync()
}
func (s *LocalMachineCredentialStore) artifactPath(spec MachineCredentialSpec) string {
return filepath.Join(s.root, spec.ID)
}
func (s *LocalMachineCredentialStore) metadataPath(spec MachineCredentialSpec) string {
return filepath.Join(s.metadata, spec.ID+".json")
}
func randomCredential(source io.Reader, bytes int) (string, error) {
if bytes < 32 {
return "", fmt.Errorf("machine credential entropy must be at least 32 bytes")
}
value := make([]byte, bytes)
if _, err := io.ReadFull(source, value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func validateMachineCredentialSpec(spec MachineCredentialSpec) error {
if strings.TrimSpace(spec.ID) == "" || strings.Contains(spec.ID, "/") || spec.Bytes < 32 || len(spec.Consumers) == 0 {
return ErrUnsupportedMachineCredential
}
switch spec.Kind {
case MachineCredentialServiceToken:
if spec.Purpose != secretcontracts.SecretPurposeServiceAuthentication {
return ErrUnsupportedMachineCredential
}
case MachineCredentialDatabaseRole:
if spec.Purpose != secretcontracts.SecretPurposeDatabasePassword {
return ErrUnsupportedMachineCredential
}
case MachineCredentialInternalAPI:
if spec.Purpose != secretcontracts.SecretPurposeServiceAuthentication && spec.Purpose != secretcontracts.SecretPurposeProviderAPIKey {
return ErrUnsupportedMachineCredential
}
default:
return ErrUnsupportedMachineCredential
}
return nil
}
func safeArtifact(info os.FileInfo) bool {
if !info.Mode().IsRegular() || info.Size() <= 0 {
return false
}
// Windows does not preserve POSIX permissions on a temporary test volume.
return runtime.GOOS == "windows" || info.Mode().Perm() == 0o400
}
func atomicPrivateFile(path string, contents []byte) error {
temp, err := os.CreateTemp(filepath.Dir(path), ".metadata-*")
if err != nil {
return err
}
tempPath := temp.Name()
defer os.Remove(tempPath)
if err := temp.Chmod(0o600); err != nil {
temp.Close()
return err
}
if _, err := temp.Write(contents); err != nil {
temp.Close()
return err
}
if err := temp.Sync(); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
return os.Rename(tempPath, path)
}