<!-- task=m-principal-provider-credential-slot-routing/07+01,02,05_secret_material plan=0 tag=API -->

# Provider credential secret material 보호 계획

## For the Implementing Agent

`CODE_REVIEW-*-G??.md`의 구현 담당 섹션 작성은 필수다. 모든 검증을 실행하고 실제 구현 메모와 stdout/stderr를 채운 뒤 active 파일을 그대로 두고 review 준비 완료를 보고한다. 막히면 정확한 blocker, 실행한 명령과 출력, 재개 조건만 구현 evidence 필드에 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하거나 control-plane stop 파일을 만들거나 다음 상태를 분류하거나 로그를 archive하거나 `complete.log`를 쓰지 않는다. 최종 판정과 archive는 code-review skill만 수행한다.

## Background

Control Plane에는 credential envelope 스키마와 secret-blind 관리 서비스가 있으나 production sealer/key registry가 없어 slot 생성·회전이 실제 배포에서 동작하지 않는다. 이 작업은 외부 key file에서 읽은 versioned AES-256-GCM keyring을 store와 service에 한 번만 주입하고, principal/slot/kind AAD를 고정해 restart와 key rotation 뒤에도 올바른 revision만 복호화되게 한다.

## Archive Evidence Snapshot

- `agent-task/archive/2026/08/m-principal-provider-credential-slot-routing/01_principal_store/complete.log`: principal/token durable ledger와 restart persistence PASS.
- `agent-task/archive/2026/08/m-principal-provider-credential-slot-routing/02+01_credential_catalog/complete.log`: slot/revision/envelope metadata와 lifecycle PASS; concrete key source는 후속 범위로 남았다.
- `agent-task/archive/2026/08/m-principal-provider-credential-slot-routing/05+01,02_management_core/complete.log`: principal-scoped management core, zeroization과 secret-blind failure surface PASS; production sealer/wire는 없었다.
- 위 세 파일의 Required finding은 없었고, 이 계획은 해당 저장·서비스 계약을 확장하되 archive를 다시 탐색하지 않는다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/principal-provider-credential-slot-routing.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/operational-observability-provider-management/milestones/principal-provider-credential-slot-routing.md)
- Task ids:
  - `secret-at-rest`: provider raw token envelope encryption과 외부 key source
- Completion mode: check-on-pass

## Analysis

### Files Read

- `agent-roadmap/phase/operational-observability-provider-management/milestones/principal-provider-credential-slot-routing.md`
- `agent-roadmap/sdd/operational-observability-provider-management/principal-provider-credential-slot-routing/SDD.md`
- `apps/control-plane/internal/credentialstore/secret_envelope.go`
- `apps/control-plane/internal/credentialstore/store.go`
- `apps/control-plane/internal/credentialstore/slot.go`
- `apps/control-plane/internal/credentialstore/slot_test.go`
- `apps/control-plane/internal/credentialops/service.go`
- `apps/control-plane/internal/credentialops/service_test.go`
- `apps/control-plane/cmd/control-plane/main.go`
- `apps/control-plane/cmd/control-plane/server.go`
- `apps/control-plane/cmd/control-plane/config_test.go`
- `apps/control-plane/cmd/control-plane/credential_store_test.go`
- `configs/control-plane.yaml`
- `go.mod`
- `agent-test/local/rules.md`
- `agent-test/local/profiles/control-plane-smoke.md`

### SDD Criteria

- 승인된 SDD의 S09/`secret-at-rest`와 Evidence Map S09-S13 중 encrypted-store inspection을 대상으로 한다.
- S09의 provider token 저장, key rotation, restart 조건에서 DB에는 algorithm/key id/version/nonce/ciphertext/AAD만 남고 올바른 key revision만 decrypt되어야 한다.
- 이 매핑에 따라 checklist는 production keyring, slot ID 선할당/AAD, startup injection, DB/log secret scan과 restart/key-version test를 반드시 포함한다.

### Verification Context

- 별도 handoff는 없었다. repository-native 근거는 `agent-test/local/rules.md`, control-plane smoke profile, 기존 credentialstore/credentialops tests와 `go.mod`다.
- 확인된 도구: `/config/.local/bin/go` (`go1.26.2 linux/arm64`), `/config/.local/bin/protoc`, `/config/.local/bin/sops`, `/bin/jq`; OS/arch는 Linux/aarch64다.
- key preflight는 값이나 내용을 읽지 않고 `/config/.config/sops/iop/*.sops.yaml`과 `/config/.config/sops/age/keys.txt`의 readable 여부만 확인했다. 이 계획의 자동 검증은 외부 provider를 호출하지 않으며 test-owned 임시 key file만 사용한다.
- precondition은 predecessor 01, 02, 05의 위 archived `complete.log`다. fresh test output이 필요하므로 Go cache는 허용하지 않고 `-count=1`을 사용한다.
- confidence: high. 실제 deployment secret-manager 제품은 선택되지 않았으므로 이 packet은 동등한 외부 secret source인 권한 제한 key file 계약까지만 소유한다.

### Test Coverage Gaps

- 기존 tests는 fake sealer와 key registry를 사용하므로 AES-GCM encrypt/decrypt, tamper, wrong AAD/key revision을 검증하지 않는다: 새 `credentialseal` tests가 필요하다.
- store tests는 envelope metadata/CAS를 검증하지만 create 시 slot ID가 seal 이후 생성되어 slot-bound AAD를 만들 수 없다: store input/transaction regression이 필요하다.
- config/startup tests는 concrete key file 누락·권한·잘못된 key length·restart reopen을 다루지 않는다: command tests를 확장한다.
- plaintext zeroization/failure-no-mutation tests는 이미 있으므로 유지하고 production sealer 경로를 추가한다.

### Symbol References

- rename/remove는 없다.
- `credentialstore.CreateSlotInput`에 optional caller-generated `SlotID`를 추가한다. 기존 composite literal call sites는 zero value 호환이며, `credentialops.Service.CreateSlot`만 새 ID를 넘긴다.
- `credentialops.SecretSealer`에는 `Open`을 추가하지 않는다. 새 keyring concrete type은 `Seal`, `Open`, `HasEnvelopeKey`를 제공하되 management service는 기존 `Seal` interface만 의존한다.

### Split Judgment

- 이 child의 stable contract는 “외부 key material + slot-bound authenticated envelope를 store/service가 restart-safe하게 읽고 쓰며 raw secret을 영속·관측하지 않는다”이다. 독립 PASS oracle은 AES/key-version tests, SQLite restart inspection, CP package tests와 secret scan이다.
- predecessor `01`은 `agent-task/archive/2026/08/m-principal-provider-credential-slot-routing/01_principal_store/complete.log`로 충족됐다.
- predecessor `02`는 `agent-task/archive/2026/08/m-principal-provider-credential-slot-routing/02+01_credential_catalog/complete.log`로 충족됐다.
- predecessor `05`는 `agent-task/archive/2026/08/m-principal-provider-credential-slot-routing/05+01,02_management_core/complete.log`로 충족됐다.
- 후속 08은 이 keyring의 `Open`과 slot revision 계약을 credential lease issuer에 사용한다.

### Scope Rationale

- management remote wire, TLS, credential lease, Node injection, migration flag와 usage labels는 08~10의 소유이므로 제외한다.
- tracked YAML에는 key 값이 아니라 비어 있는 외부 file path/key id/version 예시만 둔다. cloud KMS/Vault SDK 도입은 구체 제품 결정이 없고 새 dependency가 불필요하므로 제외한다.
- 기존 user changes와 unrelated credential management files는 수정하지 않는다.

### Final Routing

- evaluation_mode: `first-pass`; finalizer: `finalize-task-policy.sh` pair.
- build closures: scope/context/verification/evidence/ownership/decision 모두 true. scores `2,2,2,1,1` = G08; base `local-fit`, positive risks `temporal_state`, `concurrent_consistency`, `boundary_contract`, `variant_product` (4), `large_indivisible_context=false`, `review_rework_count=0`, `evidence_integrity_failure=false`; route `risk-boundary`, `PLAN-cloud-G08.md`.
- review closures 모두 true. scores `2,2,2,1,2` = G09; route `official-review`, `CODE_REVIEW-cloud-G09.md` (Codex `gpt-5.6-sol`, xhigh).
- capability gap은 없다.

## Implementation Checklist

- [ ] Implement a versioned AES-256-GCM keyring with canonical principal/slot/kind AAD, external key-file loading, decrypt support, and plaintext cleanup.
- [ ] Allocate the stable slot id before sealing, persist only authenticated envelope fields, and preserve create/rotate CAS and failure-no-mutation behavior.
- [ ] Wire the keyring into Control Plane config/startup and host-local credential commands without logging key paths, values, plaintext, or ciphertext.
- [ ] Add fresh encryption, tamper, key-version, restart, database-inspection, and secret-scan verification.
- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output.

### [API-1] Versioned authenticated keyring

**Problem:** `credentialstore.SecretEnvelope` validates only presence at `apps/control-plane/internal/credentialstore/secret_envelope.go:32-59`, while `credentialops.SecretSealer` at `apps/control-plane/internal/credentialops/service.go:36-43` has no production implementation.

**Solution:** Add `credentialseal.Keyring` backed by a strict YAML/JSON key manifest read from an operator-supplied file. Each entry contains key id, positive version, and base64-encoded exactly-32-byte AES key; duplicate id/version and insecure/empty input fail startup. Implement AES-256-GCM `Seal/Open`, generate a fresh nonce with `crypto/rand`, construct canonical length-delimited AAD from `SecretContext`, copy outputs, and zero temporary plaintext/key decode buffers. Expose `HasEnvelopeKey` so the same instance is the store registry.

Before (`apps/control-plane/internal/credentialstore/secret_envelope.go:32-40`):

```go
type SecretEnvelope struct {
	Algorithm  string `json:"algorithm"`
	KeyID      string `json:"key_id"`
	KeyVersion uint64 `json:"key_version"`
	Nonce      []byte `json:"nonce"`
	Ciphertext []byte `json:"ciphertext"`
	AAD        []byte `json:"aad,omitempty"`
}
```

After:

```go
type Keyring interface {
	credentialops.SecretSealer
	credentialstore.EnvelopeKeyRegistry
	Open(context.Context, credentialstore.SecretEnvelope, credentialops.SecretContext) ([]byte, error)
}
```

**Modified Files and Checklist:**

- [ ] Add `apps/control-plane/internal/credentialseal/aead.go` with file loader, canonical AAD, Seal/Open/HasEnvelopeKey, typed non-sensitive errors and zero helpers.
- [ ] Add `apps/control-plane/internal/credentialseal/aead_test.go` with round-trip, nonce uniqueness, tamper, wrong AAD, wrong key version, malformed manifest and restart reload tables.
- [ ] Tighten `apps/control-plane/internal/credentialstore/secret_envelope.go` algorithm/nonce/AAD validation without exposing key metadata in public errors.

**Test Strategy:** Write `TestKeyringRoundTripAndRestart`, `TestKeyringRejectsTamperScopeAndUnknownVersion`, and `TestLoadKeyringRejectsMalformedMaterial` with test-owned temp files and deterministic contexts. Assert plaintext is absent from formatted envelopes/errors.

**Verification:** `go test -count=1 ./apps/control-plane/internal/credentialseal ./apps/control-plane/internal/credentialstore` must pass.

### [API-2] Slot-bound sealing and durable inspection

**Problem:** `credentialstore.CreateSlot` generates `slotID` at `apps/control-plane/internal/credentialstore/slot.go:114-115`, after the service has already sealed the secret, so create-time AAD cannot bind ciphertext to its stable slot.

**Solution:** Add an optional `SlotID` to the store input, validate/generate it before the transaction, and have `credentialops.Service.CreateSlot` generate the UUID before calling `Seal` and pass the same ID to the store. Rotation reuses stored principal/slot/kind. Keep existing all-or-nothing SQL transaction and CAS behavior; validation/seal/open failures must not create or advance rows.

Before (`apps/control-plane/internal/credentialstore/slot.go:54-61`):

```go
type CreateSlotInput struct {
	PrincipalID    string
	Vendor         string
	CredentialKind string
	Alias          string
	Envelope       SecretEnvelope
}
```

After:

```go
type CreateSlotInput struct {
	SlotID         string
	PrincipalID    string
	Vendor         string
	CredentialKind string
	Alias          string
	Envelope       SecretEnvelope
}
```

**Modified Files and Checklist:**

- [ ] Update `apps/control-plane/internal/credentialstore/slot.go` for validated caller ID or legacy generated ID and unchanged transactional uniqueness/CAS.
- [ ] Update `apps/control-plane/internal/credentialstore/slot_test.go` for ID binding, ciphertext-only row inspection, failed seal/no mutation and key-version history.
- [ ] Update `apps/control-plane/internal/credentialops/service.go` to preallocate the slot ID, use canonical context, preserve input zeroization, and collapse crypto failures to `ErrSecretHandlingFailed`.
- [ ] Update `apps/control-plane/internal/credentialops/service_test.go` for create/rotate scope, revision, restart and non-leaking error assertions.

**Test Strategy:** Extend named tests with SQLite row queries that compare against a unique fixture secret, check no plaintext in current or revision rows, reopen the DB/keyring, decrypt both supported revisions, and reject tampered/old contexts.

**Verification:** `go test -count=1 ./apps/control-plane/internal/credentialstore ./apps/control-plane/internal/credentialops` must pass.

### [API-3] Production startup injection

**Problem:** `server.go:33` opens the credential store without an envelope registry, and `controlPlaneConfig` at `apps/control-plane/cmd/control-plane/main.go:20-40` has no external key source.

**Solution:** Add `credential_encryption.key_file`, `active_key_id`, and `active_key_version` config fields. When a database/credential plane is configured, load the keyring before store open, inject it with `WithEnvelopeKeyRegistry`, and pass it to the credential management service/CLI assembly. Missing or invalid key config fails before listeners start. Keep default DB-empty legacy startup working.

Before (`apps/control-plane/cmd/control-plane/server.go:31-39`):

```go
store, err = credentialstore.Open(ctx, cfg.Database.URL)
if err != nil {
	return fmt.Errorf("open credential store: %w", err)
}
```

After:

```go
keyring, err := credentialseal.LoadKeyring(cfg.CredentialEncryption)
store, err = credentialstore.Open(ctx, cfg.Database.URL,
	credentialstore.WithEnvelopeKeyRegistry(keyring))
```

**Modified Files and Checklist:**

- [ ] Update `apps/control-plane/cmd/control-plane/main.go` with safe config/env overrides and validation.
- [ ] Update `apps/control-plane/cmd/control-plane/server.go` to fail closed before listeners and inject one keyring instance.
- [ ] Update `apps/control-plane/cmd/control-plane/credential_commands.go` so host-local slot operations use the same external source.
- [ ] Update `apps/control-plane/cmd/control-plane/config_test.go` and `apps/control-plane/cmd/control-plane/credential_store_test.go` for legacy-empty, configured-success and missing/invalid-key failure.
- [ ] Update `configs/control-plane.yaml` with non-secret commented path/id/version fields only.

**Test Strategy:** Add config/load and command/server tests using temp key files. Assert listener startup is never reached on key errors and captured logs/config formatting contain neither encoded key nor fixture plaintext.

**Verification:** `go test -count=1 ./apps/control-plane/cmd/control-plane` must pass.

## Dependencies and Execution Order

1. Predecessors `01_principal_store`, `02+01_credential_catalog`, and `05+01,02_management_core` are satisfied by the exact archived `complete.log` files in `Split Judgment`.
2. Implement API-1, then API-2, then API-3. API-2 consumes the AAD/keyring contract; API-3 is the production composition root.
3. Do not start `08+03,04,07_secure_delivery` until this directory has `complete.log`.

## Modified Files Summary

| File | Items |
|------|-------|
| `apps/control-plane/internal/credentialseal/aead.go` | API-1 |
| `apps/control-plane/internal/credentialseal/aead_test.go` | API-1 |
| `apps/control-plane/internal/credentialstore/secret_envelope.go` | API-1 |
| `apps/control-plane/internal/credentialstore/slot.go` | API-2 |
| `apps/control-plane/internal/credentialstore/slot_test.go` | API-2 |
| `apps/control-plane/internal/credentialops/service.go` | API-2 |
| `apps/control-plane/internal/credentialops/service_test.go` | API-2 |
| `apps/control-plane/cmd/control-plane/main.go` | API-3 |
| `apps/control-plane/cmd/control-plane/server.go` | API-3 |
| `apps/control-plane/cmd/control-plane/credential_commands.go` | API-3 |
| `apps/control-plane/cmd/control-plane/config_test.go` | API-3 |
| `apps/control-plane/cmd/control-plane/credential_store_test.go` | API-3 |
| `configs/control-plane.yaml` | API-3 |
| `agent-task/m-principal-provider-credential-slot-routing/07+01,02,05_secret_material/CODE_REVIEW-cloud-G09.md` | API-1, API-2, API-3 |

## Final Verification

Run from `/config/workspace/iop-s0`; cached Go results are not acceptable.

```bash
command -v go && go version && go env GOROOT
git status --short --branch
go test -count=1 ./apps/control-plane/internal/credentialseal ./apps/control-plane/internal/credentialstore ./apps/control-plane/internal/credentialops
go test -count=1 ./apps/control-plane/cmd/control-plane
go vet ./apps/control-plane/...
rg --sort path -n --hidden --glob '!agent-task/archive/**' --glob '!*.sops.yaml' --glob '!PLAN-*.md' --glob '!CODE_REVIEW-*.md' 'BEGIN AGE PRIVATE KEY|active_key_value[[:space:]]*:[[:space:]]*[^<$]' apps/control-plane configs agent-task/m-principal-provider-credential-slot-routing/07+01,02,05_secret_material
```

Expected: all tests/vet pass; the final `rg` exits 1 with no match. Test logs must include only safe key id/version and slot reference. After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`.
