merge: GEN2-SECRETS-001 local credential provisioning
CI / test (push) Failing after 43s

This commit is contained in:
m
2026-08-31 13:18:10 +03:00
17 changed files with 795 additions and 3 deletions
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
)
const (
defaultRoot = "/run/control-plane-secrets"
defaultRuntimeUID = 100
defaultRuntimeGID = 101
)
func main() {
reason := flag.String("reason", "", "rotation reason: owner-requested-rotation or exposure-detected")
flag.Parse()
if flag.NArg() != 1 {
fail("usage: secret-control-plane [ensure|rotate]")
}
store, err := platformsecrets.NewLocalMachineCredentialStore(defaultRoot, defaultRuntimeUID, defaultRuntimeGID, nil)
if err != nil {
fail("control-plane credential store unavailable")
}
spec := platformsecrets.ControlPlaneServiceTokenSpec()
switch strings.TrimSpace(flag.Arg(0)) {
case "ensure":
result, err := store.Ensure(spec)
if err != nil {
fail("control-plane credential provisioning failed")
}
if result.Created {
fmt.Println("control-plane credential provisioned")
return
}
fmt.Println("control-plane credential already active")
case "rotate":
if !validRotationReason(*reason) {
fail("rotation reason is invalid")
}
if _, err := store.Rotate(spec, *reason); err != nil {
fail("control-plane credential rotation failed")
}
fmt.Println("control-plane credential rotated")
default:
fail("unsupported control-plane credential operation")
}
}
func validRotationReason(reason string) bool {
switch strings.TrimSpace(reason) {
case "owner-requested-rotation", "exposure-detected":
return true
default:
return false
}
}
func fail(message string) {
fmt.Fprintln(os.Stderr, message)
os.Exit(1)
}
+15
View File
@@ -0,0 +1,15 @@
package main
import "testing"
func TestSecretControlPlaneDefaultsUseDedicatedProtectedRoot(t *testing.T) {
if defaultRoot != "/run/control-plane-secrets" || defaultRuntimeUID <= 0 || defaultRuntimeGID <= 0 {
t.Fatal("unsafe control-plane defaults")
}
}
func TestRotationReasonsAreBoundedAndNeverArbitraryDiagnosticText(t *testing.T) {
if !validRotationReason("owner-requested-rotation") || !validRotationReason("exposure-detected") || validRotationReason("Bearer secret-value") {
t.Fatal("unsafe rotation reason validation")
}
}
+4 -2
View File
@@ -8,8 +8,9 @@ type CredentialRef string
type SecretPurpose string
const (
SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD"
SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY"
SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD"
SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY"
SecretPurposeServiceAuthentication SecretPurpose = "SERVICE_AUTHENTICATION"
)
// RuntimeConsumer identifies a trusted runtime allowed to resolve a credential.
@@ -17,6 +18,7 @@ type RuntimeConsumer string
const (
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
RuntimeConsumerCommit RuntimeConsumer = "commit-runtime"
RuntimeConsumerWorkWorker RuntimeConsumer = "agent-worker"
RuntimeConsumerEpistemicsWorker RuntimeConsumer = "reflection-worker"
RuntimeConsumerCognitive RuntimeConsumer = "cognitive-worker"
+4
View File
@@ -23,6 +23,7 @@ services:
SECRET_BACKEND_ROOT: /run/secrets
volumes:
- assistant_core_secrets:/run/secrets:ro
- assistant_control_plane_service_secrets:/run/control-plane-secrets:ro
- assistant_research_sources:/var/lib/research-sources:ro
networks:
shared:
@@ -138,6 +139,9 @@ services:
volumes:
assistant_core_secrets:
external: true
assistant_control_plane_service_secrets:
name: assistant_control_plane_service_secrets
external: true
assistant_research_sources:
name: assistant_research_sources
external: true
+14
View File
@@ -85,6 +85,7 @@ services:
depends_on: { postgres: { condition: service_healthy } }
volumes:
- assistant_core_secrets:/run/secrets:ro
- assistant_control_plane_service_secrets:/run/control-plane-secrets:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null && wget -qO- http://localhost:8080/readyz >/dev/null"]
interval: 10s
@@ -247,8 +248,18 @@ services:
# External mutation credentials belong here only via Portainer/Docker secrets/env injection.
depends_on: [core-runtime, continuity-runtime, notification-receiver]
volumes:
- assistant_control_plane_service_secrets:/run/control-plane-secrets:ro
- assistant_notification_writer_secret:/run/notification-writer:ro
secret-control-plane:
profiles: ["secret-control-plane"]
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: secret-control-plane, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
user: "0:0"
restart: "no"
command: ["ensure"]
volumes:
- assistant_control_plane_service_secrets:/run/control-plane-secrets
cognitive-worker:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
@@ -363,6 +374,9 @@ volumes:
assistant_nats:
assistant_core_secrets:
external: true
assistant_control_plane_service_secrets:
name: assistant_control_plane_service_secrets
external: true
assistant_notification_receipts:
assistant_notification_writer_secret:
external: true
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
# This is the only production writer for the dedicated control-plane machine
# credential volume. It never receives, prints, or exports a secret value.
BASE_DIR="${BASE_DIR:-/home/m/assistant-deploy}"
REPO_DIR="${REPO_DIR:-$BASE_DIR/repo}"
ENV_FILE="${ENV_FILE:-$BASE_DIR/production-compose.env}"
COMPOSE_FILE="$REPO_DIR/deploy/docker-compose.prod.yml"
VOLUME="assistant_control_plane_service_secrets"
ACTION="${1:-ensure}"
case "$ACTION" in
ensure|rotate) ;;
*) echo "unsupported machine credential action" >&2; exit 2 ;;
esac
[[ -f "$ENV_FILE" ]] || { echo "production compose environment is missing" >&2; exit 1; }
[[ -f "$COMPOSE_FILE" ]] || { echo "production compose file is missing" >&2; exit 1; }
compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
# `docker volume create` is idempotent and does not inspect or expose content.
docker volume create "$VOLUME" >/dev/null
revision="$(git -C "$REPO_DIR" rev-parse HEAD)"
ASSISTANT_SOURCE_REVISION="$revision" compose build --build-arg "SOURCE_REVISION=$revision" secret-control-plane >/dev/null
if [[ "$ACTION" == "rotate" ]]; then
# Only bounded, non-secret rotation reasons can reach the audit trail.
reason="${ROTATION_REASON:-owner-requested-rotation}"
case "$reason" in
owner-requested-rotation|exposure-detected) ;;
*) echo "invalid rotation reason" >&2; exit 2 ;;
esac
ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps secret-control-plane rotate -reason "$reason"
else
ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps secret-control-plane ensure
fi
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
# Regression: the wrapper accepts only an operation name and never places a
# plaintext credential on a Docker/Compose command line.
script="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/provision-machine-credential.sh"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin" "$tmp/repo/deploy"
cp "$script" "$tmp/repo/deploy/provision-machine-credential.sh"
printf 'placeholder\n' > "$tmp/repo/deploy/docker-compose.prod.yml"
printf 'placeholder\n' > "$tmp/env"
cat > "$tmp/bin/docker" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "${CAPTURE:?}"
SH
cat > "$tmp/bin/git" <<'SH'
#!/usr/bin/env bash
printf '0123456789abcdef0123456789abcdef01234567\n'
SH
chmod +x "$tmp/bin/docker" "$tmp/bin/git" "$tmp/repo/deploy/provision-machine-credential.sh"
capture="$tmp/capture"
PATH="$tmp/bin:$PATH" CAPTURE="$capture" REPO_DIR="$tmp/repo" ENV_FILE="$tmp/env" "$tmp/repo/deploy/provision-machine-credential.sh" ensure >/dev/null
grep -qx 'volume create assistant_control_plane_service_secrets' "$capture"
grep -q -- '--profile secret-control-plane run --rm --no-deps secret-control-plane ensure' "$capture"
if grep -Eqi '(token=|password=|secret=|authorization:)' "$capture"; then
echo "provisioning wrapper passed credential material" >&2
exit 1
fi
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
# Safe diagnostics only: artifact existence/readability and mount topology.
# This script never reads or prints credential content.
TOKEN_PATH="/run/control-plane-secrets/control_plane_service_token"
VOLUME="assistant_control_plane_service_secrets"
authorized=(assistant-core-runtime-1 assistant-commit-runtime-1)
unauthorized=(assistant-creator-web-1 assistant-cognitive-worker-1 assistant-research-runtime-1 assistant-continuity-runtime-1 assistant-agent-worker-1)
for container in "${authorized[@]}"; do
docker inspect "$container" >/dev/null
docker exec "$container" sh -c "test -r '$TOKEN_PATH'"
docker inspect -f '{{range .Mounts}}{{if eq .Name "assistant_control_plane_service_secrets"}}{{.Destination}} {{.RW}}{{end}}{{end}}' "$container" | grep -qx '/run/control-plane-secrets false'
done
for container in "${unauthorized[@]}"; do
docker inspect "$container" >/dev/null
if docker inspect -f '{{range .Mounts}}{{println .Name}}{{end}}' "$container" | grep -qx "$VOLUME"; then
echo "unauthorized runtime has the control-plane credential mount" >&2
exit 1
fi
done
echo "machine credential mount topology verified"
+1 -1
View File
@@ -1 +1 @@
{"task_file":"dev/tasks/GEN2-COGNITIVE-003-restore-local-medium-invocation.json","task_id":"GEN2-COGNITIVE-003","task_revision":3}
{"task_file":"dev/tasks/GEN2-SECRETS-001-local-machine-credential-provisioning.json","task_id":"GEN2-SECRETS-001","task_revision":2}
@@ -0,0 +1,28 @@
{
"task_id": "GEN2-SECRETS-001",
"task_revision": 2,
"architecture_revision": "ARCH-GEN2-000",
"base_repo_revision": "gen2-integration",
"risk_tier": "E",
"intent": "Provide an idempotent, local-only, cryptographically secure machine-credential provisioner and rotation mechanism on the existing protected Docker secret control plane, initially for the delegated control-plane service token, without creating a second semantic owner or exposing a secret value.",
"requirement_ids": ["REQ-GEN2-SECRETS-001-LOCAL-MACHINE-CREDENTIALS", "REQ-GEN2-AUTHORITY-001-DELEGATED-CONTROL-PLANE"],
"allowed_change_scope": {
"modules": ["technical protected secret file backend", "production deployment/bootstrap secret provisioning", "targeted Core/Commit service-authentication mount wiring", "targeted tests and evidence"],
"paths": ["platform/secrets/**", "contracts/secrets/types.go", "cmd/secret-control-plane/**", "cmd/core-runtime/main.go", "cmd/commit-runtime/control_plane_authority.go", "deploy/images/runtime.Dockerfile", "deploy/docker-compose.prod.yml", "deploy/docker-compose.candidate-slot.yml", "deploy/provision-machine-credential.sh", "deploy/provision-machine-credential_test.sh", "deploy/verify-machine-credential.sh", "docs/CREDENTIALS_AND_SECRETS.md", "docs/evidence/**", "development/GEN2_TASK_INDEX.md", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-SECRETS-001-local-machine-credential-provisioning.json"]
},
"forbidden_change_scope": {
"paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "cmd/devctl/**", "contracts/authority/**", "modules/authority/**", "adapters/postgres/**", "database/**", "modules/identity/**", "modules/capability/**", "cmd/cognitive-worker/**", "cmd/creator-web/**", "UI", "V1 retirement", "GEN2-EVOLVE-005", "Gen3/**", "go.mod", "go.sum"]
},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
"contracts": {"consume": ["existing CredentialRef and FileResolver", "existing Docker protected-volume deployment mechanism"], "may_change": ["typed SecretPurpose/consumer metadata only if required for the provisioner"]},
"semantic_owner": "No new semantic owner is introduced. platform/secrets remains a technical bootstrap/resolution mechanism; Identity, Authority, Capability and Configuration ownership remain unchanged.",
"canonical_state": ["credential values live only in protected local artifacts, never in Configuration, Memory, context, PostgreSQL, Compose environment, Git, logs or evidence", "machine authentication is distinct from Authority grants", "metadata/audit is separate from the value and contains no plaintext or reversible material", "the control-plane token is mounted read-only only to Core and Commit runtimes"],
"state_transitions": ["ABSENT -> ACTIVE version one via idempotent CSPRNG provisioning", "ACTIVE -> ROTATING -> ACTIVE new version after atomic artifact replacement and controlled verification", "ACTIVE -> COMPROMISED/REVOKED -> replacement version with audit retained"],
"side_effects_and_privileges": ["a one-shot local control-plane container may create or rotate only its dedicated external Docker volume", "Core and Commit get read-only mounts; all other runtimes receive no mount", "no plaintext environment variable is added"],
"persistence_and_migrations": ["no application database schema or canonical writer changes", "secret metadata and append-only audit reside separately from the secret value inside the protected control-plane volume", "rollback retains prior metadata and revokes only after a verified replacement"],
"failure_retry_restart": ["restart is idempotent and preserves the active artifact", "partial writes are avoided by atomic replacement", "a missing or unreadable artifact fails service authentication closed", "rotation never emits a credential and leaves a valid active artifact on failure"],
"assumptions": ["docker.cin.su permits creation of a dedicated external Docker volume by the existing production deployment control plane", "the runtime image can execute a one-shot local provisioner as the control-plane writer", "the existing protected secret volume pattern is the approved production mechanism"],
"acceptance_scenarios": ["missing credential is generated once with crypto/rand and then reused across restart", "metadata and audit are separate from the value", "Core and Commit mounts are read-only and no other runtime mounts the dedicated token volume", "rotation accepts the new credential and rejects the old credential", "logs and diagnostics redact known secret headers/fields", "a valid service credential alone does not bypass the existing delegated Authority grant checks"],
"required_tests": ["targeted go test ./platform/secrets ./cmd/secret-control-plane", "targeted go vet ./platform/secrets ./cmd/secret-control-plane", "compose config validation", "provisioner shell regression", "preflight", "check-diff", "independent", "git diff --check"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
}
+4
View File
@@ -1,5 +1,9 @@
# DOMOVOY Gen2 — Task Index
## GEN2-SECRETS-001 — Local Machine Credential Provisioning & Rotation
Bounded post-M5 remediation. Provides the technical protected-secret control-plane mechanism required by `GEN2-AUTHORITY-001`; it does not change semantic ownership or grant Authority.
Это исполняемый индекс task packets. `ARCH-GEN2-000` соответствует разделу `GEN2-000` Master Plan: префикс `ARCH-` нужен текущему `devctl`, чтобы последующие packets могли ссылаться на него через `architecture_revision`.
| Порядок | Task | Milestone | Gate | Base ref | Зависимость | Файл |
+4
View File
@@ -23,6 +23,10 @@ Each privileged runtime gets only its required secret/capability bindings. `comm
The Core SELF learning repository uses the existing `assistant_core_secrets` volume and credential-reference mechanism with `self_role` and `credential://domovoy/self/db-password/v1`. The secret is consumed from the fixed `/run/secrets/self_db_password` path; its value is never placed in Compose environment, Git, logs or ordinary configuration. The role and schema already exist; this wiring does not create a role or broaden privileges.
Delegated non-interactive control-plane work is separately authenticated. The local `secret-control-plane` one-shot control-plane service provisions `control_plane_service_token` with `crypto/rand` into the dedicated external `assistant_control_plane_service_secrets` volume. The token artifact is mode `0400`; metadata and audit are separate protected files and contain no value or reversible fingerprint. Only `core-runtime` and the existing Authority/Commit runtime mount that volume read-only at `/run/control-plane-secrets`; no model, Skill, SELF, creator-web, cognitive-worker or research-runtime receives the mount. Compose carries only the non-secret credential reference `credential://domovoy/control-plane/service-token/v1`.
The provisioner is idempotent: restart reuses an existing safe active artifact. Rotation creates a new value by atomic replacement, records non-secret metadata/audit, and never accepts a plaintext environment variable. The service token is not a Creator session, cannot impersonate a human, and never grants Authority by itself. Removing or rotating the token disables/refreshes service authentication; revoking the durable Authority grant also blocks new delegated operations without deleting audit history.
Creator authentication secrets are stored in the external Docker volume `assistant_core_secrets` and mounted read-only only at `/run/secrets` in `core-runtime`. The production container environment contains `_FILE` paths, not the secret values. Files are owned by runtime uid 100/gid 101 with mode 0400. `CREATOR_USERNAME` is not secret and remains an environment value. Development may continue to use direct environment values, but a direct value and its matching `_FILE` setting must never be configured together.
The PostgreSQL off-host backup passphrase is generated randomly and stored as CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\postgres-backup-key.dpapi.json`. An atomic copy of that DPAPI ciphertext, never the plaintext passphrase, is retained at `\\nas\MST\codex\_backups\domovoy\keys\postgres-backup-key.dpapi.json`. The passphrase is never stored in Git, the production Docker host, a scheduled-task argument, or NAS plaintext. The NAS backup directory receives only GnuPG AES-256 ciphertext and a non-secret integrity manifest. Recovery therefore requires both the NAS artifacts and the authorized Windows DPAPI profile.
@@ -0,0 +1,35 @@
# GEN2-SECRETS-001 — local machine credential provisioning
Date: 2026-08-31
## Design evidence
`platform/secrets` is the existing technical protected-file mechanism. This
task extends it without adding a semantic Secrets owner, a database table, or
a Configuration/Memory writer.
The dedicated Docker volume is named
`assistant_control_plane_service_secrets`. Its sole writer is the one-shot
`secret-control-plane` control-plane container. Its only consumers are
`core-runtime` and `commit-runtime`, mounted read-only at
`/run/control-plane-secrets`. The metadata directory is distinct from the
token artifact and records purpose, owner, version, consumers, status,
generation method, and non-secret audit actions only.
The provisioner uses `crypto/rand`, writes via an atomic file replacement,
and has no plaintext environment input. It reports only safe operation status.
The data policy marks the artifact as not indexable, embeddable, retrievable,
or model-visible.
## Local FAST evidence
The following checks completed successfully before production execution:
- targeted `go test ./platform/secrets ./cmd/secret-control-plane`;
- targeted `go vet ./platform/secrets ./cmd/secret-control-plane`;
- POSIX shell regression for the provisioning wrapper, executed under Git
Bash because the Windows WSL shim cannot translate the mapped workspace;
- static Docker Compose validation for production and candidate files.
No credential value, fingerprint, length, header, or plaintext environment
value was read, printed, copied, committed, or included in this evidence.
+399
View File
@@ -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)
}
@@ -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)
}
}
+24
View File
@@ -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")
}
+10
View File
@@ -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)
}
}