diff --git a/cmd/secret-control-plane/main.go b/cmd/secret-control-plane/main.go new file mode 100644 index 0000000..50d232e --- /dev/null +++ b/cmd/secret-control-plane/main.go @@ -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) +} diff --git a/cmd/secret-control-plane/main_test.go b/cmd/secret-control-plane/main_test.go new file mode 100644 index 0000000..159ff17 --- /dev/null +++ b/cmd/secret-control-plane/main_test.go @@ -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") + } +} diff --git a/deploy/docker-compose.candidate-slot.yml b/deploy/docker-compose.candidate-slot.yml index 3d38a7f..d0b4790 100644 --- a/deploy/docker-compose.candidate-slot.yml +++ b/deploy/docker-compose.candidate-slot.yml @@ -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: @@ -139,6 +140,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 diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index cefb78d..52b5515 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -71,6 +71,7 @@ services: CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1 + CONTROL_PLANE_SERVICE_SECRET_ROOT: /run/control-plane-secrets SELF_POSTGRES_USER: self_role SELF_POSTGRES_CREDENTIAL_REF: credential://domovoy/self/db-password/v1 EPISTEMICS_POSTGRES_USER: epistemics_role @@ -86,6 +87,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 @@ -241,6 +243,7 @@ services: AUTHORITY_POSTGRES_USER: authority_role AUTHORITY_POSTGRES_PASSWORD: ${AUTHORITY_DB_PASSWORD} CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1 + CONTROL_PLANE_SERVICE_SECRET_ROOT: /run/control-plane-secrets SECRET_BACKEND_ROOT: /run/secrets CORE_RUNTIME_URL: http://core-runtime:8080 CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081 @@ -251,8 +254,18 @@ services: depends_on: [core-runtime, continuity-runtime, notification-receiver] volumes: - assistant_core_secrets:/run/secrets:ro + - 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 @@ -367,6 +380,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 diff --git a/deploy/provision-machine-credential.sh b/deploy/provision-machine-credential.sh new file mode 100644 index 0000000..5acbb31 --- /dev/null +++ b/deploy/provision-machine-credential.sh @@ -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 -reason "$reason" rotate +else + ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps secret-control-plane ensure +fi diff --git a/deploy/provision-machine-credential_test.sh b/deploy/provision-machine-credential_test.sh new file mode 100644 index 0000000..147b208 --- /dev/null +++ b/deploy/provision-machine-credential_test.sh @@ -0,0 +1,31 @@ +#!/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" +PATH="$tmp/bin:$PATH" CAPTURE="$capture" REPO_DIR="$tmp/repo" ENV_FILE="$tmp/env" ROTATION_REASON=owner-requested-rotation "$tmp/repo/deploy/provision-machine-credential.sh" rotate >/dev/null +grep -q -- '--profile secret-control-plane run --rm --no-deps secret-control-plane -reason owner-requested-rotation rotate' "$capture" +if grep -Eqi '(token=|password=|secret=|authorization:)' "$capture"; then + echo "provisioning wrapper passed credential material" >&2 + exit 1 +fi diff --git a/deploy/verify-machine-credential.sh b/deploy/verify-machine-credential.sh new file mode 100644 index 0000000..77d5859 --- /dev/null +++ b/deploy/verify-machine-credential.sh @@ -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" diff --git a/dev/tasks/GEN2-SECRETS-001-local-machine-credential-provisioning.json b/dev/tasks/GEN2-SECRETS-001-local-machine-credential-provisioning.json new file mode 100644 index 0000000..acd2686 --- /dev/null +++ b/dev/tasks/GEN2-SECRETS-001-local-machine-credential-provisioning.json @@ -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"] +} diff --git a/development/GEN2_TASK_INDEX.md b/development/GEN2_TASK_INDEX.md index 6d3f956..2f5f1ca 100644 --- a/development/GEN2_TASK_INDEX.md +++ b/development/GEN2_TASK_INDEX.md @@ -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 | Зависимость | Файл | diff --git a/docs/CREDENTIALS_AND_SECRETS.md b/docs/CREDENTIALS_AND_SECRETS.md index 97043cf..d8cb9dd 100644 --- a/docs/CREDENTIALS_AND_SECRETS.md +++ b/docs/CREDENTIALS_AND_SECRETS.md @@ -23,7 +23,9 @@ 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 external `assistant_core_secrets` control plane must materialize `control_plane_service_token`, mode `0400`, for `core-runtime` and the existing Authority/Commit runtime only. Compose carries only `credential://domovoy/control-plane/service-token/v1`; trusted runtimes resolve the fixed file below `/run/secrets`. The service token is not a Creator session, cannot impersonate a human, and must be rotated or withdrawn in the secret backend. Removing the token disables new delegated operations; revoking the durable Authority grant also blocks them without deleting audit history. +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. diff --git a/docs/evidence/gen2-secrets-001-local-provisioning-2026-08-31.md b/docs/evidence/gen2-secrets-001-local-provisioning-2026-08-31.md new file mode 100644 index 0000000..a82a052 --- /dev/null +++ b/docs/evidence/gen2-secrets-001-local-provisioning-2026-08-31.md @@ -0,0 +1,53 @@ +# 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. + +## Production control-plane evidence + +The one-shot provisioner was run from a detached `gen2-integration` worktree +on `docker.cin.su`. It created the dedicated external volume and provisioned +the credential once. A repeat `ensure` reported that the already active +artifact was reused. A bounded owner-requested rotation then completed. + +For creation, idempotent ensure, and rotation, the stable Core, Commit, +Creator Web, Cognitive Worker, and Research Runtime container identities were +recorded before and after the one-shot operation and were unchanged. The +verification checked only artifact and separate metadata/audit file presence; +it did not read their content. + +At this point the credential is provisioned but not yet used by the stable +runtime. Consumer mount and service-authentication acceptance are completed +only when `GEN2-AUTHORITY-001` is merged and deployed as its own controlled +candidate ReleaseSet. 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) + } +}