405 lines
14 KiB
Go
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)
|
|
}
|