From 3a8c6d4827abdec192aca69ad2958a33f0dc9bdd Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 31 Aug 2026 13:17:56 +0300 Subject: [PATCH] feat(secrets): add protected credential store --- platform/secrets/machine_credentials.go | 399 +++++++++++++++++++ platform/secrets/machine_credentials_test.go | 96 +++++ platform/secrets/redaction.go | 24 ++ platform/secrets/redaction_test.go | 10 + 4 files changed, 529 insertions(+) create mode 100644 platform/secrets/machine_credentials.go create mode 100644 platform/secrets/machine_credentials_test.go create mode 100644 platform/secrets/redaction.go create mode 100644 platform/secrets/redaction_test.go diff --git a/platform/secrets/machine_credentials.go b/platform/secrets/machine_credentials.go new file mode 100644 index 0000000..8b34b71 --- /dev/null +++ b/platform/secrets/machine_credentials.go @@ -0,0 +1,399 @@ +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" + 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 (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) +} diff --git a/platform/secrets/machine_credentials_test.go b/platform/secrets/machine_credentials_test.go new file mode 100644 index 0000000..fc4eff4 --- /dev/null +++ b/platform/secrets/machine_credentials_test.go @@ -0,0 +1,96 @@ +package secrets + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + secretcontracts "git.cin.su/m/Assistant/contracts/secrets" +) + +func TestMachineCredentialEnsureIsIdempotentAndKeepsValueOutOfMetadata(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + store, err := NewLocalMachineCredentialStore(t.TempDir(), -1, -1, func() time.Time { return now }) + if err != nil { + t.Fatal(err) + } + first, err := store.Ensure(ControlPlaneServiceTokenSpec()) + if err != nil || !first.Created || first.Metadata.Version != 1 { + t.Fatalf("first provision = %#v, %v", first, err) + } + second, err := store.Ensure(ControlPlaneServiceTokenSpec()) + if err != nil || second.Created || second.Metadata.Version != 1 { + t.Fatalf("second provision = %#v, %v", second, err) + } + metadata, err := os.ReadFile(filepath.Join(store.metadata, ControlPlaneServiceTokenID+".json")) + if err != nil { + t.Fatal(err) + } + artifact, err := os.ReadFile(filepath.Join(store.root, ControlPlaneServiceTokenID)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(metadata), strings.TrimSpace(string(artifact))) { + t.Fatal("metadata unexpectedly contains a credential value") + } + info, err := os.Stat(filepath.Join(store.root, ControlPlaneServiceTokenID)) + if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o400) { + t.Fatalf("artifact mode = %v, %v", info.Mode(), err) + } +} + +func TestMachineCredentialSpecSupportsMachineCredentialKinds(t *testing.T) { + cases := []MachineCredentialSpec{ + ControlPlaneServiceTokenSpec(), + {ID: "database_role_password", Kind: MachineCredentialDatabaseRole, Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumers: []secretcontracts.RuntimeConsumer{secretcontracts.RuntimeConsumerCore}, Bytes: 32}, + {ID: "internal_api_credential", Kind: MachineCredentialInternalAPI, Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumers: []secretcontracts.RuntimeConsumer{secretcontracts.RuntimeConsumerCommit}, Bytes: 32}, + } + for _, value := range cases { + if err := validateMachineCredentialSpec(value); err != nil { + t.Fatalf("credential kind %s is unsupported: %v", value.Kind, err) + } + } +} + +func TestMachineCredentialRotateAtomicallyReplacesArtifactAndAdvancesVersion(t *testing.T) { + store, err := NewLocalMachineCredentialStore(t.TempDir(), -1, -1, time.Now) + if err != nil { + t.Fatal(err) + } + if _, err := store.Ensure(ControlPlaneServiceTokenSpec()); err != nil { + t.Fatal(err) + } + path := filepath.Join(store.root, ControlPlaneServiceTokenID) + oldValue, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + rotated, err := store.Rotate(ControlPlaneServiceTokenSpec(), "exposure detected") + if err != nil || !rotated.Rotated || rotated.Metadata.Version != 2 { + t.Fatalf("rotation = %#v, %v", rotated, err) + } + newValue, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(oldValue) == string(newValue) { + t.Fatal("rotation retained old credential") + } +} + +func TestMachineCredentialRejectsUnsafeExistingArtifact(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ControlPlaneServiceTokenID), 0o700); err != nil { + t.Fatal(err) + } + store, err := NewLocalMachineCredentialStore(root, -1, -1, time.Now) + if err != nil { + t.Fatal(err) + } + if _, err := store.Ensure(ControlPlaneServiceTokenSpec()); err != ErrCredentialArtifactUnsafe { + t.Fatalf("unsafe artifact error = %v", err) + } +} diff --git a/platform/secrets/redaction.go b/platform/secrets/redaction.go new file mode 100644 index 0000000..33dbd0f --- /dev/null +++ b/platform/secrets/redaction.go @@ -0,0 +1,24 @@ +package secrets + +import "strings" + +const Redacted = "REDACTED" + +// RedactDiagnosticFields removes known credential/header values before a +// diagnostics boundary. Callers must still avoid passing raw values at all. +func RedactDiagnosticFields(fields map[string]string) map[string]string { + redacted := make(map[string]string, len(fields)) + for key, value := range fields { + if isSensitiveDiagnosticField(key) { + redacted[key] = Redacted + continue + } + redacted[key] = value + } + return redacted +} + +func isSensitiveDiagnosticField(key string) bool { + key = strings.ToLower(strings.TrimSpace(key)) + return key == "authorization" || key == "proxy-authorization" || strings.Contains(key, "secret") || strings.Contains(key, "token") || strings.Contains(key, "password") || strings.Contains(key, "credential") +} diff --git a/platform/secrets/redaction_test.go b/platform/secrets/redaction_test.go new file mode 100644 index 0000000..2e1cacd --- /dev/null +++ b/platform/secrets/redaction_test.go @@ -0,0 +1,10 @@ +package secrets + +import "testing" + +func TestRedactDiagnosticFieldsNeverReturnsKnownCredentialFields(t *testing.T) { + fields := RedactDiagnosticFields(map[string]string{"Authorization": "Bearer value", "control_plane_service_token": "value", "status": "PASS"}) + if fields["Authorization"] != Redacted || fields["control_plane_service_token"] != Redacted || fields["status"] != "PASS" { + t.Fatalf("unexpected diagnostic redaction: %#v", fields) + } +}