merge: GEN2-SECRETS-001 into delegated authority

# Conflicts:
#	deploy/docker-compose.prod.yml
#	dev/ACTIVE_TASK.json
#	docs/CREDENTIALS_AND_SECRETS.md
This commit is contained in:
m
2026-08-31 13:26:28 +03:00
15 changed files with 811 additions and 1 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
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:
@@ -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
+16
View File
@@ -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
+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 -reason "$reason" rotate
else
ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps secret-control-plane ensure
fi
@@ -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
+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"
@@ -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 | Зависимость | Файл |
+3 -1
View File
@@ -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.
@@ -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.
+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)
}
}