diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/architect/handoff.json b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/architect/handoff.json new file mode 100644 index 00000000..a41a5d5f --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/architect/handoff.json @@ -0,0 +1,8 @@ +{ + "bodyBytes": 10432, + "characterId": "architect", + "displayName": "Dan Johnson", + "phase": "run", + "roleHint": "architect", + "writtenAt": "2026-06-12T18:19:28.233435638Z" +} \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/architect/handoff.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/architect/handoff.md new file mode 100644 index 00000000..361efce9 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/architect/handoff.md @@ -0,0 +1,219 @@ +Good — I have the full source of both files. Now let me produce the architectural review. + +--- + +# Architect Handoff — Bug: `sc secrets add/hide` fails on UTF-8 secret files + +**Workflow run:** `08762da2-22ac-4162-abc6-60b46e7a89c5` +**Date:** 2026-06-12 +**Author:** Dan Johnson (architect) +**Status:** Architecture review complete ✅ — cleared for implementation + +--- + +## 1. Code Reality Check + +I read the actual source at `pkg/api/secrets/ciphers/encryption.go`. The PM's analysis is **correct in every detail**. Confirming live against the file: + +```go +// CONFIRMED in source — the offending line: +chunks := lo.ChunkString(s, rsaKey.Size()/2) +``` + +- `rsaKey.Size()` for a 2048-bit key = 256. +- `rsaKey.Size()/2` = 128. +- `lo.ChunkString` operates on **runes** (it converts to `[]rune` internally). +- The byte-level OAEP-SHA256 limit = 256 − 2×32 − 2 = **190 bytes**. +- A 128-rune window of 3-byte UTF-8 characters = up to 384 bytes >> 190 → guaranteed failure. + +The bug is exactly as described. No ambiguity. + +--- + +## 2. Architectural Assessment + +### 2.1 The Two-Compounding-Bugs Pattern + +``` +Input string (UTF-8) + │ + ▼ +lo.ChunkString(s, 128) ← counts RUNES, not bytes + │ + ▼ +[]byte(chunk) ← silent expansion: 1 rune → up to 4 bytes + │ + ▼ +rsa.EncryptOAEP(sha256, data) ← hard byte limit = 190 bytes + │ + ▼ +PANIC: message too long for RSA key size (if chunk > 190 bytes) +``` + +The two bugs **compound**: the chunk size is already a rough heuristic (`Size()/2` is not the OAEP formula), and the rune-vs-byte mismatch turns it into a live correctness bug for any non-ASCII input. + +### 2.2 Why the Fix is Safe + +The proposed fix chunks by **bytes** at `maxPlain = rsaKey.Size() - 2*sha256.Size() - 2`: + +- This is the exact OAEP formula from RFC 8017 §7.1.1. No magic constant. +- Splitting a UTF-8 multi-byte sequence across chunk boundaries is **safe** because `DecryptLargeString` reconstructs the original byte sequence: it decrypts each chunk to `[]byte`, and the existing `strings.Join(lo.Map(...))` path reassembles them. A split multi-byte sequence in chunk `n` and `n+1` is perfectly valid once concatenated. +- `DecryptLargeString` is chunk-size agnostic — it decrypts whatever list it receives. Existing ciphertexts produced by the rune-based chunking will still decrypt correctly. + +### 2.3 Import Cleanup — Critical Check + +`lo.ChunkString` is **the only use** of `github.com/samber/lo` in the RSA branch of `EncryptLargeString`. However, `lo.Map` is **still used in `DecryptLargeString`**: + +```go +return []byte(strings.Join(lo.Map(decrChunks, func(chunk []byte, _ int) string { + return string(chunk) +}), "")), nil +``` + +**Therefore: do NOT remove the `lo` import.** The import stays. The PM's caveat ("verify — `lo` may still be used elsewhere") applies: it is used. + +### 2.4 `EncryptWithPublicRSAKey` — Hash Inconsistency + +Confirmed in source: +```go +func EncryptWithPublicRSAKey(msg []byte, pub *rsa.PublicKey) ([]byte, error) { + hash := sha512.New() // SHA-512: max 256 - 2*64 - 2 = 126 bytes for 2048-bit key +``` + +vs. + +```go +// EncryptLargeString RSA branch: +rsa.EncryptOAEP(sha256.New(), ...) // SHA-256: max 190 bytes for 2048-bit key +``` + +This is a latent inconsistency — not the active bug, but a trap for anyone who calls `EncryptWithPublicRSAKey` with payloads between 127–190 bytes expecting them to work. The `TODO` comment is the right call for this slice. + +### 2.5 `DecryptLargeString` — No Changes Needed (But Note the Smell) + +```go +return []byte(strings.Join(lo.Map(decrChunks, func(chunk []byte, _ int) string { + return string(chunk) +}), "")), nil +``` + +This does a `[]byte → string → join → []byte` round-trip which is technically fine for valid UTF-8 but is mildly wasteful and semantically confusing (it converts binary data through the `string` type). This is a NICE-TO-HAVE cleanup — `bytes.Join(decrChunks, nil)` is cleaner — but it is **not blocking** for this fix. Do not change it in this slice. + +--- + +## 3. Decision Record + +| # | Decision | Rationale | +|---|----------|-----------| +| DR-1 | Chunk by bytes using `rsaKey.Size() - 2*sha256.Size() - 2` | Correct OAEP formula; removes all ambiguity | +| DR-2 | Drop `lo.ChunkString` call only, keep `lo` import | `lo.Map` still used in `DecryptLargeString` | +| DR-3 | No change to `DecryptLargeString` | Backward compat; chunk-size agnostic; byte reconstruction works | +| DR-4 | Add `maxPlain <= 0` guard | Defensive: explicit error beats panic for tiny/bad keys | +| DR-5 | Add TODO comment to `EncryptWithPublicRSAKey` | Documents SHA-512 vs SHA-256 inconsistency without breaking anything | +| DR-6 | Do NOT change `DecryptLargeString`'s `bytes.Join` pattern | Out of scope; no risk; separate cleanup | +| DR-7 | Do NOT standardise hash algorithm in this slice | Breaking change to existing ciphertexts; separate PR | + +--- + +## 4. Complete Revised `EncryptLargeString` RSA Branch + +This is the exact implementation the developer should produce: + +```go +func EncryptLargeString(key crypto.PublicKey, s string) ([]string, error) { + var res []string + if rsaKey, ok := key.(*rsa.PublicKey); ok { + // RSA-OAEP with SHA-256: max plaintext per chunk = k - 2*hLen - 2 + // (RFC 8017 §7.1.1), where k = modulus bytes, hLen = sha256.Size() = 32. + // For a 2048-bit key: 256 - 64 - 2 = 190 bytes. + // Chunk by BYTES (not runes) so multi-byte UTF-8 cannot overflow the limit. + maxPlain := rsaKey.Size() - 2*sha256.Size() - 2 + if maxPlain <= 0 { + return nil, errors.Errorf( + "RSA key too small (%d bits) for OAEP-SHA256", rsaKey.Size()*8) + } + data := []byte(s) + res = make([]string, 0, (len(data)+maxPlain-1)/maxPlain) + for i := 0; i < len(data); i += maxPlain { + end := i + maxPlain + if end > len(data) { + end = len(data) + } + enc, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, rsaKey, data[i:end], nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to encrypt secret") + } + res = append(res, base64.StdEncoding.EncodeToString(enc)) + } + } else if ed25519Key, ok := key.(ed25519.PublicKey); ok { + // ed25519 branch unchanged — uses hybrid encryption (no chunking needed) + encryptedData, err := encryptWithEd25519(ed25519Key, []byte(s)) + if err != nil { + return nil, errors.Wrapf(err, "failed to encrypt secret with ed25519") + } + res = []string{base64.StdEncoding.EncodeToString(encryptedData)} + } else { + return nil, errors.New("unsupported key type for encryption") + } + return res, nil +} +``` + +And the TODO to add to `EncryptWithPublicRSAKey`: + +```go +// EncryptWithPublicRSAKey encrypts data with public key using RSA-OAEP + SHA-512. +// TODO: this uses SHA-512 (max plaintext 126 B for a 2048-bit key) while +// EncryptLargeString uses SHA-256 (max 190 B). Standardise in a future PR. +// Do NOT route payloads > 126 B through this function. +func EncryptWithPublicRSAKey(msg []byte, pub *rsa.PublicKey) ([]byte, error) { +``` + +--- + +## 5. Test Matrix (for developer reference) + +| Test | Input | Expected | +|------|-------|----------| +| UTF-8 box-drawing flood | `strings.Repeat("─", 120)` = 360 bytes in 120 runes | Encrypt succeeds; round-trip passes | +| Mixed multi-byte | `→` `—` `→` Cyrillic `я` emoji `🔑` × N, total > 190 bytes | Encrypt succeeds; round-trip passes | +| Exactly 190 ASCII bytes | `strings.Repeat("a", 190)` | 1 chunk; encrypt + decrypt = original | +| 191 ASCII bytes | `strings.Repeat("a", 191)` | 2 chunks; encrypt + decrypt = original | +| 4096-bit key round-trip | Large UTF-8 payload, 4096-bit key | `maxPlain` = 446; fewer chunks; correct | +| Empty string | `""` | 0 chunks; decrypt returns `""` | +| Existing (rune-chunked) ciphertexts | Old chunks in `DecryptLargeString` | Decrypt unchanged; not tested in unit test but backward-compat by design | + +The test for the old code failing is not necessary to include (it would require temporarily reverting the fix). A comment referencing the bug is sufficient documentation. + +--- + +## 6. Potential Risks / Things Developer Should Watch + +1. **`lo` import must stay** — removing it causes a compile error in `DecryptLargeString`. +2. **`errors.Wrapf` not `errors.Wrap`** — existing code uses `Wrapf` for the ed25519 branch; match the style. +3. **`rand.Reader` argument** — `rsa.EncryptOAEP` takes `io.Reader` as second arg. The existing code correctly passes `rand.Reader`; keep it. +4. **Test file package** — existing tests are `package ciphers` (same package, not `_test`), so they can directly call unexported helpers. New tests must match this. +5. **4096-bit key behaviour** — `maxPlain` for a 4096-bit key = 512 − 64 − 2 = 446. Tests covering both 2048 and 4096-bit keys are welcome but not required for this slice. + +--- + +## 7. Out of Scope (Architect Concurs) + +- **Hybrid encryption for RSA path** — The ed25519 path already uses ChaCha20-Poly1305 hybrid correctly. Migrating RSA to the same pattern would be the right long-term architecture but requires a migration plan for existing secrets. Separate slice. +- **`EncryptWithPublicRSAKey` hash unification** — Changing SHA-512→SHA-256 there would break the decrypt side for any data currently encrypted with that function. Separate PR with a migration. +- **`DecryptLargeString` cleanup** — `bytes.Join(decrChunks, nil)` is cleaner than the current `strings.Join(lo.Map(...))` path but has zero user impact. Separate cleanup. + +--- + +## 8. Handoff to Developer + +**The architectural review is clean. This is a surgical, well-understood fix:** + +1. **File:** `pkg/api/secrets/ciphers/encryption.go` +2. **Change:** Replace the `lo.ChunkString` block in `EncryptLargeString` RSA branch with the byte-slicing loop above (§4). +3. **Do not remove the `lo` import** — it is still used by `DecryptLargeString`. +4. **Add TODO comment** to `EncryptWithPublicRSAKey` (§4). +5. **Tests:** Add the cases in §5 to `encryption_test.go`. Use `package ciphers`, `RegisterTestingT(t)`, gomega matchers — match existing style. +6. **Run:** `go test ./pkg/api/secrets/ciphers/...` must pass green before handoff. + +No schema changes. No API surface changes. No config changes. No CLI changes. Ship it. \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/architect/summary.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/architect/summary.md new file mode 100644 index 00000000..8d3ecfcc --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/architect/summary.md @@ -0,0 +1 @@ +Good — I have the full source of both files. Now let me produce the architectural review. \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/developer/handoff.json b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/developer/handoff.json new file mode 100644 index 00000000..a6530869 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/developer/handoff.json @@ -0,0 +1,8 @@ +{ + "bodyBytes": 2718, + "characterId": "developer", + "displayName": "David Black", + "phase": "run", + "roleHint": "developer", + "writtenAt": "2026-06-12T18:22:52.976250198Z" +} \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/developer/handoff.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/developer/handoff.md new file mode 100644 index 00000000..bfaf654a --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/developer/handoff.md @@ -0,0 +1,70 @@ +PR is live at https://github.com/simple-container-com/api/pull/321. + +--- + +# Developer Handoff — Bug: `sc secrets add/hide` fails on UTF-8 secret files + +**Workflow run:** `08762da2-22ac-4162-abc6-60b46e7a89c5` +**Date:** 2026-06-12 +**Author:** David Black (developer) +**Status:** Implementation complete ✅ — tests green, committed and pushed, PR open + +--- + +## What was done + +### 1. Root cause fix — `pkg/api/secrets/ciphers/encryption.go` + +Replaced the `lo.ChunkString` (rune-based) call in `EncryptLargeString` RSA branch with a byte-slice loop using the exact OAEP formula: + +```go +maxPlain := rsaKey.Size() - 2*sha256.Size - 2 // 190 for 2048-bit key +``` + +**Note:** `sha256.Size` is an untyped integer constant (= 32), not a function — `sha256.Size()` would not compile. + +The `lo` import was deliberately retained — `lo.Map` is still used in `DecryptLargeString`. + +### 2. TODO comment — `EncryptWithPublicRSAKey` + +Added the comment documenting the SHA-512 vs SHA-256 inconsistency, as scoped by PM/architect. + +### 3. Regression tests — `pkg/api/secrets/ciphers/encryption_test.go` + +Added `TestEncryptLargeStringUTF8` with 6 sub-tests: + +| Sub-test | Input | Validates | +|---|---|---| +| box-drawing flood | 120 × U+2500 (360 bytes, 120 runes) | The original bug case | +| mixed multi-byte | `→—яя🔑` × 20 (~280 bytes) | General multi-byte UTF-8 | +| exactly 190 ASCII bytes | `strings.Repeat("a", 190)` | Boundary: 1 chunk | +| 191 ASCII bytes | `strings.Repeat("a", 191)` | Boundary: 2 chunks | +| empty string | `""` | Zero-chunk edge case | +| 4096-bit key | 300 × U+2500 (900 bytes) | `maxPlain = 446`; 3 chunks | + +All 6 pass. All pre-existing tests pass. + +--- + +## Commit and PR + +- **Commit:** `75f5eddaed7dcb4772efe2a3238f64c0afa66bb7` +- **Branch:** `forge/workflow/99171cbe-7c27-4976-a999-931b834b5d62/run/08762da2-22ac-4162-abc6-60b46e7a89c5` +- **PR:** https://github.com/simple-container-com/api/pull/321 +- **Workflow run:** https://app.simple-forge.com/agent-workflow-runs/08762da2-22ac-4162-abc6-60b46e7a89c5 + +`PULL_REQUEST: https://github.com/simple-container-com/api/pull/321` + +--- + +## One implementation note + +The PM/architect spec used `sha256.Size()` (function call syntax). In Go's `crypto/sha256` package, `Size` is a typed constant (`= 32`), not a method. The correct expression is `2*sha256.Size` without parentheses. This was caught immediately by the compiler on the first test run and corrected. + +--- + +## Out of scope (not done, as agreed) + +- `DecryptLargeString` bytes-join cleanup (`bytes.Join` vs current `strings.Join(lo.Map(...))`) +- `EncryptWithPublicRSAKey` hash unification (SHA-512 → SHA-256) — breaking change +- Hybrid encryption for RSA path — separate slice \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/developer/summary.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/developer/summary.md new file mode 100644 index 00000000..7c695489 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/developer/summary.md @@ -0,0 +1 @@ +PR is live at https://github.com/simple-container-com/api/pull/321. \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/devops/handoff.json b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/devops/handoff.json new file mode 100644 index 00000000..cba1d66a --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/devops/handoff.json @@ -0,0 +1,8 @@ +{ + "bodyBytes": 6681, + "characterId": "devops", + "displayName": "William Smith", + "phase": "run", + "roleHint": "devops", + "writtenAt": "2026-06-12T18:34:48.222969477Z" +} \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/devops/handoff.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/devops/handoff.md new file mode 100644 index 00000000..66bf8ee8 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/devops/handoff.md @@ -0,0 +1,162 @@ +Good. The PR is live and open at https://github.com/simple-container-com/api/pull/321. I have a clear picture of the state. Now I produce my DevOps handoff. + +--- + +# DevOps Handoff — Bug: `sc secrets add/hide` fails on UTF-8 secret files + +**Workflow run:** `08762da2-22ac-4162-abc6-60b46e7a89c5` +**Date:** 2026-06-12 +**Author:** William Smith (devops) +**PR:** https://github.com/simple-container-com/api/pull/321 +**Commit reviewed:** `75f5eddaed7dcb4772efe2a3238f64c0afa66bb7` (fix), head `32351e8a10e22bf449e17cc497c68e9ed48cd89e` + +--- + +## 1. Overall Assessment + +The fix is **correct and mergeable** from a DevOps perspective. The CI picture from QA is confirmed: all substantive checks pass. One infrastructure issue (DCO) requires resolution before merge. No deployment changes, no infra changes, no rollout complexity. + +--- + +## 2. CI Status Review (from PR live fetch) + +| Check | Status | Notes | +|---|---|---| +| Run tests | **pass** | All 6 new UTF-8 regression tests + full existing suite | +| Build Setup (lint, fmt, tools, schemas) | **pass** | Clean | +| Build sc (linux/amd64, darwin/amd64, darwin/arm64) | **pass** | All targets | +| Build cloud-helpers, github-actions, github-actions-staging | **pass** | | +| Finalize build | **pass** | | +| Fuzz HMAC cache | **pass** | | +| govulncheck | **pass** | | +| security (Secret Scan, SBOM, Status) | **pass** | | +| semgrep | **pass** | 0 findings confirmed by bot comment on PR | +| Analyze Go (CodeQL) | pending/long-running | No failures observed | +| **DCO** | **FAIL** | Infrastructure issue — see §3 | + +--- + +## 3. DCO Failure — Root Cause and Fix + +### What failed + +The DCO (Developer Certificate of Origin) bot rejects all 6 commits on the branch because none carry a `Signed-off-by:` trailer. This includes the 4 Forge Workflow Engine scaffolding commits (author `forge-agent@simple-forge.com`) and the actual fix commit (`75f5edd`). + +### Why this is infrastructure, not code + +The DCO bot checks every commit author. The Forge Workflow Engine emits commits on behalf of the workflow run — it cannot retroactively sign off on commits it has already made without a force-push. The fix commit itself (`75f5edd`) is also unsigned because the developer agent did not configure `git commit --signoff`. + +### Remediation options (pick one) + +**Option A — Recommended: Configure DCO bot to exempt `forge-agent@` author** + +Add to `.github/dco.yml` in `main`: + +```yaml +# .github/dco.yml +allowRemediationCommits: + individual: true + organization: true +require: + members: false +allowedPattern: "^forge-agent\\+.*@simple-forge\\.com$" +``` + +Or use the DCO bot's `exemptByCommitEmail` config (exact key depends on the DCO Action version in use): + +```yaml +# .github/workflows/dco.yml (or whatever triggers the bot) +# Add to the DCO action step: +- uses: dco-org/dco-action@v1 + with: + exempt-authors: forge-agent+.*@simple-forge.com +``` + +Check the current DCO Action version in `.github/workflows/` and match its config API. + +**Option B — One-off remediation: force-push with signed commits** + +```bash +git checkout forge/workflow/99171cbe-7c27-4976-a999-931b834b5d62/run/08762da2-22ac-4162-abc6-60b46e7a89c5 +# Rebase and sign all commits +git rebase --signoff main +git push --force-with-lease +``` + +⚠ This rewrites history and invalidates review state. Only do this if Option A cannot be done first. + +**Option C — Merge via admin bypass** + +If the fix is urgent and the DCO check is non-blocking for admins, a repository admin can merge with "Merge without waiting for requirements to be met (bypass branch protection rules)". Not recommended as standing practice. + +**My recommendation:** Option A — fix the root cause in the CI infrastructure so all future Forge workflow PRs don't hit this. Option C as an unblock for this specific PR if it's urgent and Option A is in flight. + +--- + +## 4. Deployment Impact + +**Zero.** This is a pure library/crypto fix: + +- No service changes +- No API surface changes +- No config changes +- No database migrations +- No Simple Container `client.yaml` / `server.yaml` changes +- No secrets rotation required (existing encrypted secrets still decrypt correctly; re-encryption after `sc secrets hide` produces correct new chunks) + +The fix ships in the next `sc` binary build, which CI already confirmed builds successfully for all targets. No rollout strategy needed beyond normal release flow. + +--- + +## 5. Operational Risk Assessment + +| Risk | Severity | Notes | +|---|---|---| +| Breaking existing secret decryption | **None** | `DecryptLargeString` is chunk-size agnostic; old ciphertexts unchanged | +| New encryption incompatible with old decrypt | **None** | Byte-based chunks are a subset of valid inputs to the existing decoder | +| Binary size / dependency change | **None** | `lo` import retained; no new dependencies | +| Re-encryption required for existing secrets | **None** | Old ciphertexts are valid; re-encrypt is optional but safe | +| DCO block on merge | **Low** | Process issue, not a code defect; bypassed or fixed at infra level | + +--- + +## 6. Post-Merge Actions + +1. **Track the DCO infrastructure fix** — create an internal ticket to add `forge-agent@` exemption to the DCO bot config before the next workflow PR lands. +2. **Optional: bulk re-encrypt secrets with non-ASCII content** — `sc secrets hide` on affected files after the fix is deployed. Not required; existing secrets still decrypt. +3. **Notify users affected by the original bug** — any teams who worked around the bug by stripping non-ASCII from secrets files can revert those changes. +4. **Future: hybrid encryption for RSA path** — track as a separate ticket. The ed25519 path already uses ChaCha20-Poly1305 hybrid. The RSA path should eventually be migrated (no chunking, no size bugs). Not blocking. + +--- + +## 7. Handoff Summary + +| Item | Status | +|---|---| +| Code fix correct | ✅ | +| Tests covering bug case | ✅ | +| CI green (substantive) | ✅ | +| DCO resolved | ❌ Infrastructure action required | +| Deployment changes | None required | +| Merge readiness | **Ready once DCO is unblocked** | + +**Recommended next action:** Unblock merge via DCO bot exemption (Option A) or admin bypass (Option C), then merge to `main`. No other gate is blocking. + +`PULL_REQUEST: https://github.com/simple-container-com/api/pull/321` + +--- + +```json +{ + "run_id": "08762da2-22ac-4162-abc6-60b46e7a89c5", + "from": "devops", + "to": null, + "date": "2026-06-12", + "artifact": "devops-handoff", + "pr": "https://github.com/simple-container-com/api/pull/321", + "merge_ready": true, + "blocker": "DCO bot — infrastructure fix required, not a code defect", + "deployment_changes": false, + "chainContinue": false +} +``` \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/devops/summary.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/devops/summary.md new file mode 100644 index 00000000..ab2aada5 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/devops/summary.md @@ -0,0 +1 @@ +Good. The PR is live and open at https://github.com/simple-container-com/api/pull/321. I have a clear picture of the state. Now I produce my DevOps handoff. \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/pm/handoff.json b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/pm/handoff.json new file mode 100644 index 00000000..c4ede0d2 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/pm/handoff.json @@ -0,0 +1,8 @@ +{ + "bodyBytes": 6672, + "characterId": "pm", + "displayName": "Max Warner", + "phase": "run", + "roleHint": "pm", + "writtenAt": "2026-06-12T18:17:56.807048011Z" +} \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/pm/handoff.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/pm/handoff.md new file mode 100644 index 00000000..eb133c11 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/pm/handoff.md @@ -0,0 +1,162 @@ +Now I have a thorough picture of the codebase. Let me produce the PM handoff. + +--- + +# PM Handoff — Bug: `sc secrets add/hide` fails on UTF-8 secret files + +**Workflow run:** `08762da2-22ac-4162-abc6-60b46e7a89c5` +**Date:** 2026-06-12 +**Author:** Max Warner (pm) +**Status:** Scoped ✅ — ready for developer + +--- + +## 1. Problem Statement (one line) + +`EncryptLargeString` chunks by **rune count** instead of **byte count**, causing `rsa.EncryptOAEP` to receive payloads that exceed the OAEP-SHA256 byte limit whenever a chunk contains multi-byte UTF-8 characters. + +--- + +## 2. Scope Decision Table + +| # | Item | Label | Rationale | +|---|------|-------|-----------| +| 1 | Fix `EncryptLargeString`: chunk by bytes using `maxPlain = rsaKey.Size() - 2*sha256.Size() - 2` | **MUST** | Direct root cause; one-function change | +| 2 | Remove the `lo.ChunkString` (rune-based) call; replace with byte-slice loop | **MUST** | Required to implement #1 | +| 3 | Add regression test: 128-rune UTF-8 window > 190 bytes fails under old code, passes under new | **MUST** | Prevents silent re-regression; fast to write | +| 4 | Add guard: return explicit error if `maxPlain <= 0` (RSA key too small) | **MUST** | Defensive; 2 lines | +| 5 | Note `EncryptWithPublicRSAKey` hash inconsistency (SHA-512 vs SHA-256) in a `TODO` comment | **MUST** | Documents latent risk; zero cost | +| 6 | Standardise hash across `EncryptWithPublicRSAKey` / `EncryptLargeString` | **NICE-TO-HAVE** | Breaks existing ciphertext round-trip; separate PR | +| 7 | Full hybrid-encryption rewrite (AES-GCM / ChaCha20 for RSA path) | **NICE-TO-HAVE** | Longer-term; separate slice | +| 8 | Migrate `DecryptLargeString` to byte-native join | **NICE-TO-HAVE** | Current `strings.Join` of `string(chunk)` works; no functional regression | + +--- + +## 3. Exact Change Required + +**File:** `pkg/api/secrets/ciphers/encryption.go` +**Function:** `EncryptLargeString` — RSA branch only + +### Before (lines ~147–157) + +```go +if rsaKey, ok := key.(*rsa.PublicKey); ok { + chunks := lo.ChunkString(s, rsaKey.Size()/2) // rune-count chunks — BUG + res = make([]string, len(chunks)) + for idx, chunk := range chunks { + encryptedData, err := rsa.EncryptOAEP( + sha256.New(), rand.Reader, rsaKey, []byte(chunk), nil) + ... + res[idx] = base64.StdEncoding.EncodeToString(encryptedData) + } +} +``` + +### After + +```go +if rsaKey, ok := key.(*rsa.PublicKey); ok { + // RSA-OAEP max plaintext = k - 2·hLen - 2 (SHA-256 → hLen=32). + // Chunk by BYTES, not runes, to handle multi-byte UTF-8 safely. + maxPlain := rsaKey.Size() - 2*sha256.Size() - 2 // 190 for 2048-bit + if maxPlain <= 0 { + return nil, errors.Errorf( + "RSA key too small (%d bits) for OAEP-SHA256", rsaKey.Size()*8) + } + data := []byte(s) + res = make([]string, 0, (len(data)+maxPlain-1)/maxPlain) + for i := 0; i < len(data); i += maxPlain { + end := i + maxPlain + if end > len(data) { + end = len(data) + } + enc, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, rsaKey, data[i:end], nil) + if err != nil { + return nil, errors.Wrap(err, "failed to encrypt secret") + } + res = append(res, base64.StdEncoding.EncodeToString(enc)) + } +} +``` + +Also add to `EncryptWithPublicRSAKey` (near line 86): + +```go +// TODO: this uses SHA-512 (max plaintext 126 B for 2048-bit key) while +// EncryptLargeString uses SHA-256 (max 190 B). Standardise in a future PR; +// do NOT route payloads > 126 B through this function. +``` + +And **remove** the now-unused `"github.com/samber/lo"` import if it is no longer referenced elsewhere in the file. (Verify — `lo` may still be used elsewhere; if not, drop it to keep the build clean.) + +--- + +## 4. Test Requirements + +**File to modify:** `pkg/api/secrets/ciphers/encryption_test.go` (or add to `encryption_extra_test.go`) + +| Test case | What it proves | +|-----------|----------------| +| `EncryptLargeString` with 120× `─` (U+2500, 3 bytes each = 360 bytes in 120 runes) | Old code would fail; new code must pass | +| `EncryptLargeString` with mixed ASCII + multi-byte (e.g. `→`, `—`, Cyrillic, emoji) totalling > 190 bytes in a single window | Confirms general UTF-8 safety | +| Round-trip: encrypt with new code, decrypt with `DecryptLargeString`, result == original string | Backward-compatible decryption | +| Input of exactly 190 bytes (all ASCII) — single chunk | Boundary: 190 B ≤ maxPlain → exactly 1 chunk | +| Input of 191 bytes — split into 2 chunks | Boundary: forces the loop to produce 2 chunks | + +--- + +## 5. Backward Compatibility + +| Scenario | Impact | +|----------|--------| +| Existing secrets encrypted with old (rune-based) chunks | **No change** — `DecryptLargeString` is chunk-list–driven; chunk sizes are independent | +| Re-encrypting (`sc secrets hide`) after the fix | Produces new byte-based chunks; decrypts correctly | +| `DecryptLargeString` code | **No changes required** | + +--- + +## 6. Acceptance Criteria + +1. `go test ./pkg/api/secrets/ciphers/...` passes with all new test cases green. +2. `printf '# %s\n' "$(python3 -c 'print("─"*120)')" > /tmp/secret.txt && sc secrets add /tmp/secret.txt` succeeds (smoke test). +3. No `lo.ChunkString` call remains in `EncryptLargeString`. +4. `maxPlain` is derived from `rsaKey.Size() - 2*sha256.Size() - 2` (not a magic constant). +5. `EncryptWithPublicRSAKey` carries the `TODO` hash-inconsistency comment. + +--- + +## 7. Out of Scope (this slice) + +- Hybrid encryption for RSA path (longer-term; separate slice) +- `EncryptWithPublicRSAKey` hash unification (separate PR; breaking change) +- Any changes to CLI layer (`sc secrets add/hide`) +- Any changes to decryption path + +--- + +## 8. Handoff JSON + +```json +{ + "run_id": "08762da2-22ac-4162-abc6-60b46e7a89c5", + "from": "pm", + "to": "developer", + "date": "2026-06-12", + "artifact": "pm-handoff", + "bug": "sc secrets add/hide fails on UTF-8 secret files — crypto/rsa message too long", + "affected_file": "pkg/api/secrets/ciphers/encryption.go", + "affected_function": "EncryptLargeString", + "fix_summary": "Replace lo.ChunkString (rune-based) with byte-slice loop using maxPlain = rsaKey.Size() - 2*sha256.Size() - 2", + "test_file": "pkg/api/secrets/ciphers/encryption_test.go", + "must_items": [1, 2, 3, 4, 5], + "nice_to_have_items": [6, 7, 8], + "backward_compatible": true, + "slice_id": null +} +``` + +--- + +## 9. Next Step + +**Developer:** implement the byte-based chunking change in `pkg/api/secrets/ciphers/encryption.go` and add the regression tests described in §4. This is a ~30-line surgical change. No API surface, no schema, no config changes. Ship it. \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/pm/summary.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/pm/summary.md new file mode 100644 index 00000000..769b3cd1 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/pm/summary.md @@ -0,0 +1 @@ +Now I have a thorough picture of the codebase. Let me produce the PM handoff. \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/qa/handoff.json b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/qa/handoff.json new file mode 100644 index 00000000..314b1884 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/qa/handoff.json @@ -0,0 +1,8 @@ +{ + "bodyBytes": 5096, + "characterId": "qa", + "displayName": "Maria Currie", + "phase": "run", + "roleHint": "qa", + "writtenAt": "2026-06-12T18:33:44.261152565Z" +} \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/qa/handoff.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/qa/handoff.md new file mode 100644 index 00000000..920463cc --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/qa/handoff.md @@ -0,0 +1,115 @@ +The full picture is now clear. Every check is **pass** except: +- **DCO** — fail (infrastructure issue, all workflow-engine commits, not a code defect) +- **Analyze Go** — still pending (CodeQL analysis; no indication of failure) + +The entire build matrix, all tests, security scans, linting, fuzz, and vuln checks are green. My handoff above stands. Here is the final, complete version for the engine: + +--- + +# QA Handoff — Bug: `sc secrets add/hide` fails on UTF-8 secret files + +**Workflow run:** `08762da2-22ac-4162-abc6-60b46e7a89c5` +**Date:** 2026-06-12 +**Author:** Maria Currie (qa) +**PR:** https://github.com/simple-container-com/api/pull/321 +**Commit reviewed:** `75f5eddaed7dcb4772efe2a3238f64c0afa66bb7` + +**Verdict:** signoff + +--- + +## 1. Review scope + +Read both changed files in full from the PR branch via MCP. Verified the complete diff. Monitored CI to final settlement. + +**Files changed:** +- `pkg/api/secrets/ciphers/encryption.go` — 21 additions, 6 deletions +- `pkg/api/secrets/ciphers/encryption_test.go` — 80 additions + +--- + +## 2. Code correctness — `encryption.go` + +| Item | Finding | +|---|---| +| Chunk size formula | `rsaKey.Size() - 2*sha256.Size - 2` = 190 for 2048-bit key. RFC 8017 §7.1.1 exact. ✓ | +| `sha256.Size` constant | Untyped constant = 32, not a function call. Compiles correctly. ✓ | +| Byte-based loop | `data := []byte(s)` then `data[i:end]` — no rune counting. Root cause eliminated. ✓ | +| Capacity pre-alloc | `(len(data)+maxPlain-1)/maxPlain` — correct ceiling division. ✓ | +| Bound clamp | `if end > len(data) { end = len(data) }` — no out-of-bounds. ✓ | +| `maxPlain <= 0` guard | Present; descriptive error for undersized keys. ✓ | +| `lo` import retained | Correct — `lo.Map` still used in `DecryptLargeString`. ✓ | +| `DecryptLargeString` | Untouched. Backward compatibility preserved. ✓ | +| ed25519 branch | Untouched. ✓ | +| TODO on `EncryptWithPublicRSAKey` | Present with SHA-512 / 126-byte limit warning. ✓ | + +--- + +## 3. Test coverage — `encryption_test.go` + +All 6 sub-tests in `TestEncryptLargeStringUTF8` correct and complete: + +| Sub-test | Input | Validates | +|---|---|---| +| box-drawing flood | 120 × `─` = 360 bytes / 120 runes | Exact reported bug case | +| mixed multi-byte | `→—яя🔑` × 20 ≈ 280 bytes | General multi-byte UTF-8 | +| exactly 190 bytes ASCII | `strings.Repeat("a", 190)` → `HaveLen(1)` | Upper boundary | +| 191 bytes ASCII | `strings.Repeat("a", 191)` → `HaveLen(2)` | Forces split | +| empty string | `""` → `BeEmpty()` | Zero-chunk edge case | +| 4096-bit key | 300 × `─` = 900 bytes | `maxPlain=446` scaling | + +Style matches existing file conventions throughout. + +--- + +## 4. Final CI results + +| Check | Result | +|---|---| +| Run tests | **pass** | +| Build Setup (clean, tools, schemas, lint, fmt) | **pass** | +| Build sc (linux/amd64, darwin/amd64, darwin/arm64) | **pass** | +| Build cloud-helpers, github-actions, github-actions-staging | **pass** | +| Finalize build in branch | **pass** | +| Fuzz HMAC cache parse path | **pass** | +| govulncheck (reachability-aware) | **pass** | +| security (Secret Scan, SBOM, Status) | **pass** | +| semgrep (all jobs) | **pass** | +| Analyze Go (CodeQL) | pending (long-running analysis; no failures in any other check) | +| **Developer Certificate of Origin** | **FAIL** — infrastructure issue only (see below) | + +### DCO failure + +All 5 commits on the branch are missing `Signed-off-by` trailers, including the 4 Forge Workflow Engine scaffolding commits authored by `forge-agent@simple-forge.com`. This is a process/infrastructure gap — the DCO bot has no exemption for workflow-engine authors. The code is correct. DevOps action required: add `Signed-off-by` to the engine commit template or configure DCO bot to exempt `forge-agent@` author addresses. + +--- + +## 5. Acceptance criteria + +| Criterion | Status | +|---|---| +| `go test ./pkg/api/secrets/ciphers/...` green | **MET** — CI `Run tests` passed | +| No `lo.ChunkString` in `EncryptLargeString` | **MET** | +| `maxPlain` uses exact OAEP formula | **MET** | +| `EncryptWithPublicRSAKey` TODO comment present | **MET** | +| 120× `─` input encrypts without error | **MET** — sub-test 1 covers this directly | + +All 5 PM MUST items met. All 3 NICE-TO-HAVE items correctly deferred. + +--- + +## 6. Findings + +1. **[INFO] DCO policy failure** — infrastructure issue, not a code defect. Affects all workflow-engine commits. DevOps to resolve. +2. **[INFO] Analyze Go (CodeQL) still running** — long-running static analysis job; all other checks clean, no indication of issues. +3. **[INFO] `DecryptLargeString` byte-join smell** — `strings.Join(lo.Map(...))` could be `bytes.Join(decrChunks, nil)`. No correctness impact; deferred out of scope. + +**No blocking findings.** + +--- + +## 7. Disposition + +The fix is correct, complete, and well-tested. The full build matrix and all substantive CI checks are green. The only failing check is DCO — a process/infrastructure matter unrelated to code quality. + +**Verdict:** signoff — ready for merge once DCO infrastructure is resolved. \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/qa/summary.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/qa/summary.md new file mode 100644 index 00000000..de9502e1 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/agents/qa/summary.md @@ -0,0 +1,3 @@ +The full picture is now clear. Every check is **pass** except: +- **DCO** — fail (infrastructure issue, all workflow-engine commits, not a code defect) +- **Analyze Go** — still pending (CodeQL analysis; no indication of failure) \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/brief.md b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/brief.md new file mode 100644 index 00000000..058a6678 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/brief.md @@ -0,0 +1,152 @@ +Bug: `sc secrets add/hide` fails on UTF-8 secret files — "crypto/rsa: message too long for RSA key size" + +## Summary + +`sc secrets add` / `sc secrets hide` fail to encrypt a secret file when the file +contains enough **multi-byte UTF-8** characters (emoji, box-drawing `─`, arrows +`→ ↔`, em-dash `—`, Cyrillic, CJK, accented letters) clustered together. The +command aborts with: + +``` +Error executing command: failed to re-encrypt all secrets: + failed to encrypt secret file: "" with publicKey "ssh-rsa AAAAB3N": + failed to encrypt secret: crypto/rsa: message too long for RSA key size +``` + +The failure is **data-dependent and intermittent**: an ASCII-only file of the +same size encrypts fine; a file with a dense run of multi-byte characters fails. +This makes it confusing to diagnose (it looks like a key-size problem, but all +keys are 2048-bit and the file is far smaller than any RSA limit). + +## Impact + +- Any secret file with non-ASCII content (comments with box-drawing/arrows, + emoji in values, Cyrillic/CJK text) can become un-encryptable. +- Severity: medium. Blocks adding/rotating affected secrets; no data loss. +- Real case: a host_vars file with section-header comments built from `─` and + `→ ↔ —` characters (worst 128-rune window = 260 bytes) failed; the same file + with those replaced by ASCII (`-`, `->`, `<->`, `--`) encrypted fine. + +## Root cause + +`pkg/api/secrets/ciphers/encryption.go`, `EncryptLargeString` (≈ line 147): + +```go +func EncryptLargeString(key crypto.PublicKey, s string) ([]string, error) { + if rsaKey, ok := key.(*rsa.PublicKey); ok { + chunks := lo.ChunkString(s, rsaKey.Size()/2) // <-- (1) chunk size in RUNES + for idx, chunk := range chunks { + encryptedData, err := rsa.EncryptOAEP( + sha256.New(), rand.Reader, rsaKey, []byte(chunk), nil) // <-- (2) OAEP byte limit + ... + } + } +} +``` + +Two compounding issues: + +1. **Chunking is by runes, the limit is in bytes.** `lo.ChunkString` splits the + string by **rune count** (it operates on `[]rune`). The chunk is then converted + back with `[]byte(chunk)`. For multi-byte UTF-8, the byte length of a chunk can + be up to 4× its rune count. + +2. **The chunk size `rsaKey.Size()/2` is not a safe OAEP message size.** + RSA-OAEP can encrypt at most `k − 2·hLen − 2` bytes, where `k` = modulus size + in bytes and `hLen` = hash output size. With SHA-256 (`hLen = 32`) and a + 2048-bit key (`k = 256`): **max = 256 − 64 − 2 = 190 bytes**. + The code uses `rsaKey.Size()/2 = 128` as the chunk size. + + - For **ASCII**: 128 runes = 128 bytes ≤ 190 → OK (which is why it usually works). + - For **multi-byte UTF-8**: a 128-rune chunk can be 129…512 bytes. As soon as + one chunk exceeds 190 bytes, `rsa.EncryptOAEP` returns + `crypto/rsa: message too long for RSA key size`. + +So `Size()/2` only *happens* to be safe for ASCII; it is unsafe in general +because (a) it counts runes not bytes and (b) `Size()/2` > the true OAEP limit +once content is multi-byte. + +### Verification (math + measured) + +- 2048-bit key: OAEP-SHA256 limit = 190 bytes; code chunk size = 128. +- Measured worst-case 128-rune window (bytes): + - ASCII-heavy host_vars: 160 B, 168 B → encrypt OK. + - Box-drawing-heavy host_vars: **260 B** → encrypt FAILS. + +## Reproduction + +```sh +# A file whose 128-rune window exceeds 190 bytes when UTF-8 encoded. +printf '# %s\n' "$(python3 -c 'print("─"*120)')" > /tmp/secret.txt # 120x U+2500 = 360 bytes +sc secrets add /tmp/secret.txt +# -> crypto/rsa: message too long for RSA key size +``` + +## Proposed fix + +Chunk by **bytes** with a size that respects the OAEP limit, and operate on the +byte slice instead of runes. Splitting a multi-byte character across chunk +boundaries is safe here because `DecryptLargeString` concatenates the decrypted +byte chunks back exactly (`strings.Join` of the per-chunk strings reproduces the +original byte sequence; Go strings are byte sequences, so an invalid-UTF-8 +fragment in one chunk is harmless once joined). + +```go +func EncryptLargeString(key crypto.PublicKey, s string) ([]string, error) { + if rsaKey, ok := key.(*rsa.PublicKey); ok { + // RSA-OAEP max message = k - 2*hLen - 2 (SHA-256 => hLen=32). Leave a + // small safety margin. + maxPlain := rsaKey.Size() - 2*sha256.Size() - 2 // 190 for 2048-bit + if maxPlain <= 0 { + return nil, errors.Errorf("RSA key too small (%d bits) for OAEP-SHA256", rsaKey.Size()*8) + } + data := []byte(s) + res := make([]string, 0, (len(data)+maxPlain-1)/maxPlain) + for i := 0; i < len(data); i += maxPlain { + end := i + maxPlain + if end > len(data) { + end = len(data) + } + enc, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, rsaKey, data[i:end], nil) + if err != nil { + return nil, errors.Wrap(err, "failed to encrypt secret") + } + res = append(res, base64.StdEncoding.EncodeToString(enc)) + } + return res, nil + } + // ... ed25519 branch unchanged ... +} +``` + +`DecryptLargeString` needs no change (it already decodes + RSA-decrypts each +chunk and joins the resulting bytes). + +### Backward compatibility + +Existing secrets stored with the old rune-based chunking still decrypt correctly +(decryption is chunk-list driven and chunk-size agnostic). Only **re-encryption** +(add/hide) produces the new byte-based chunks. A `--force` re-encrypt of the whole +store after the fix is safe. + +### Alternative (recommended longer term) + +Switch the RSA path to **hybrid encryption**, mirroring the existing ed25519 +path (`encryptWithEd25519`): generate a random symmetric key, encrypt the file +once with AES-GCM / ChaCha20-Poly1305, and RSA-OAEP-encrypt only the small +symmetric key. This eliminates chunking entirely, is faster, and removes this +whole class of size bug. + +## Secondary note (latent inconsistency) + +`EncryptWithPublicRSAKey` (same file, ≈ line 86) does a **single-shot** +`rsa.EncryptOAEP` with **SHA-512** (`hLen=64` → max 126 bytes for a 2048-bit key) +and no chunking, whereas `EncryptLargeString` uses **SHA-256** + chunking. If any +caller routes large data through `EncryptWithPublicRSAKey`, it will fail for +anything > 126 bytes. Consider standardizing the hash and always going through +the chunked/hybrid path. + +## Affected file + +- `pkg/api/secrets/ciphers/encryption.go` — `EncryptLargeString` (chunking), + and `EncryptWithPublicRSAKey` (hash inconsistency). \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/inputs.json b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/inputs.json new file mode 100644 index 00000000..e3df13d4 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/inputs.json @@ -0,0 +1,6 @@ +{ + "brief": "Bug: `sc secrets add/hide` fails on UTF-8 secret files — \"crypto/rsa: message too long for RSA key size\"\n\n## Summary\n\n`sc secrets add` / `sc secrets hide` fail to encrypt a secret file when the file\ncontains enough **multi-byte UTF-8** characters (emoji, box-drawing `─`, arrows\n`→ ↔`, em-dash `—`, Cyrillic, CJK, accented letters) clustered together. The\ncommand aborts with:\n\n```\nError executing command: failed to re-encrypt all secrets:\n failed to encrypt secret file: \"\u003cpath\u003e\" with publicKey \"ssh-rsa AAAAB3N\":\n failed to encrypt secret: crypto/rsa: message too long for RSA key size\n```\n\nThe failure is **data-dependent and intermittent**: an ASCII-only file of the\nsame size encrypts fine; a file with a dense run of multi-byte characters fails.\nThis makes it confusing to diagnose (it looks like a key-size problem, but all\nkeys are 2048-bit and the file is far smaller than any RSA limit).\n\n## Impact\n\n- Any secret file with non-ASCII content (comments with box-drawing/arrows,\n emoji in values, Cyrillic/CJK text) can become un-encryptable.\n- Severity: medium. Blocks adding/rotating affected secrets; no data loss.\n- Real case: a host_vars file with section-header comments built from `─` and\n `→ ↔ —` characters (worst 128-rune window = 260 bytes) failed; the same file\n with those replaced by ASCII (`-`, `-\u003e`, `\u003c-\u003e`, `--`) encrypted fine.\n\n## Root cause\n\n`pkg/api/secrets/ciphers/encryption.go`, `EncryptLargeString` (≈ line 147):\n\n```go\nfunc EncryptLargeString(key crypto.PublicKey, s string) ([]string, error) {\n if rsaKey, ok := key.(*rsa.PublicKey); ok {\n chunks := lo.ChunkString(s, rsaKey.Size()/2) // \u003c-- (1) chunk size in RUNES\n for idx, chunk := range chunks {\n encryptedData, err := rsa.EncryptOAEP(\n sha256.New(), rand.Reader, rsaKey, []byte(chunk), nil) // \u003c-- (2) OAEP byte limit\n ...\n }\n }\n}\n```\n\nTwo compounding issues:\n\n1. **Chunking is by runes, the limit is in bytes.** `lo.ChunkString` splits the\n string by **rune count** (it operates on `[]rune`). The chunk is then converted\n back with `[]byte(chunk)`. For multi-byte UTF-8, the byte length of a chunk can\n be up to 4× its rune count.\n\n2. **The chunk size `rsaKey.Size()/2` is not a safe OAEP message size.**\n RSA-OAEP can encrypt at most `k − 2·hLen − 2` bytes, where `k` = modulus size\n in bytes and `hLen` = hash output size. With SHA-256 (`hLen = 32`) and a\n 2048-bit key (`k = 256`): **max = 256 − 64 − 2 = 190 bytes**.\n The code uses `rsaKey.Size()/2 = 128` as the chunk size.\n\n - For **ASCII**: 128 runes = 128 bytes ≤ 190 → OK (which is why it usually works).\n - For **multi-byte UTF-8**: a 128-rune chunk can be 129…512 bytes. As soon as\n one chunk exceeds 190 bytes, `rsa.EncryptOAEP` returns\n `crypto/rsa: message too long for RSA key size`.\n\nSo `Size()/2` only *happens* to be safe for ASCII; it is unsafe in general\nbecause (a) it counts runes not bytes and (b) `Size()/2` \u003e the true OAEP limit\nonce content is multi-byte.\n\n### Verification (math + measured)\n\n- 2048-bit key: OAEP-SHA256 limit = 190 bytes; code chunk size = 128.\n- Measured worst-case 128-rune window (bytes):\n - ASCII-heavy host_vars: 160 B, 168 B → encrypt OK.\n - Box-drawing-heavy host_vars: **260 B** → encrypt FAILS.\n\n## Reproduction\n\n```sh\n# A file whose 128-rune window exceeds 190 bytes when UTF-8 encoded.\nprintf '# %s\\n' \"$(python3 -c 'print(\"─\"*120)')\" \u003e /tmp/secret.txt # 120x U+2500 = 360 bytes\nsc secrets add /tmp/secret.txt\n# -\u003e crypto/rsa: message too long for RSA key size\n```\n\n## Proposed fix\n\nChunk by **bytes** with a size that respects the OAEP limit, and operate on the\nbyte slice instead of runes. Splitting a multi-byte character across chunk\nboundaries is safe here because `DecryptLargeString` concatenates the decrypted\nbyte chunks back exactly (`strings.Join` of the per-chunk strings reproduces the\noriginal byte sequence; Go strings are byte sequences, so an invalid-UTF-8\nfragment in one chunk is harmless once joined).\n\n```go\nfunc EncryptLargeString(key crypto.PublicKey, s string) ([]string, error) {\n if rsaKey, ok := key.(*rsa.PublicKey); ok {\n // RSA-OAEP max message = k - 2*hLen - 2 (SHA-256 =\u003e hLen=32). Leave a\n // small safety margin.\n maxPlain := rsaKey.Size() - 2*sha256.Size() - 2 // 190 for 2048-bit\n if maxPlain \u003c= 0 {\n return nil, errors.Errorf(\"RSA key too small (%d bits) for OAEP-SHA256\", rsaKey.Size()*8)\n }\n data := []byte(s)\n res := make([]string, 0, (len(data)+maxPlain-1)/maxPlain)\n for i := 0; i \u003c len(data); i += maxPlain {\n end := i + maxPlain\n if end \u003e len(data) {\n end = len(data)\n }\n enc, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, rsaKey, data[i:end], nil)\n if err != nil {\n return nil, errors.Wrap(err, \"failed to encrypt secret\")\n }\n res = append(res, base64.StdEncoding.EncodeToString(enc))\n }\n return res, nil\n }\n // ... ed25519 branch unchanged ...\n}\n```\n\n`DecryptLargeString` needs no change (it already decodes + RSA-decrypts each\nchunk and joins the resulting bytes).\n\n### Backward compatibility\n\nExisting secrets stored with the old rune-based chunking still decrypt correctly\n(decryption is chunk-list driven and chunk-size agnostic). Only **re-encryption**\n(add/hide) produces the new byte-based chunks. A `--force` re-encrypt of the whole\nstore after the fix is safe.\n\n### Alternative (recommended longer term)\n\nSwitch the RSA path to **hybrid encryption**, mirroring the existing ed25519\npath (`encryptWithEd25519`): generate a random symmetric key, encrypt the file\nonce with AES-GCM / ChaCha20-Poly1305, and RSA-OAEP-encrypt only the small\nsymmetric key. This eliminates chunking entirely, is faster, and removes this\nwhole class of size bug.\n\n## Secondary note (latent inconsistency)\n\n`EncryptWithPublicRSAKey` (same file, ≈ line 86) does a **single-shot**\n`rsa.EncryptOAEP` with **SHA-512** (`hLen=64` → max 126 bytes for a 2048-bit key)\nand no chunking, whereas `EncryptLargeString` uses **SHA-256** + chunking. If any\ncaller routes large data through `EncryptWithPublicRSAKey`, it will fail for\nanything \u003e 126 bytes. Consider standardizing the hash and always going through\nthe chunked/hybrid path.\n\n## Affected file\n\n- `pkg/api/secrets/ciphers/encryption.go` — `EncryptLargeString` (chunking),\n and `EncryptWithPublicRSAKey` (hash inconsistency).", + "issueId": "6a2c25990ce79d1156204861", + "issueNumber": 318, + "issueRepo": "simple-container-com/api" +} \ No newline at end of file diff --git a/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/spec.json b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/spec.json new file mode 100644 index 00000000..ded1c416 --- /dev/null +++ b/.workflow/08762da2-22ac-4162-abc6-60b46e7a89c5/spec.json @@ -0,0 +1,125 @@ +{ + "initialCharacterId": "pm", + "characters": [ + { + "id": "pm", + "agentId": "34890141-d95a-4628-9507-35a06c70827e", + "displayName": "Max Warner", + "roleHint": "pm", + "capabilities": { + "hasDocker": false, + "hasPersistentDisk": false, + "maxTurnDurationSec": 1800, + "supportsLongLived": false, + "isolation": "process", + "egressPolicy": "scoped", + "minMemoryMB": 512 + }, + "description": "Product Manager — Max Warner (autonomous slice-id addition shipped 2026-06-11)." + }, + { + "id": "architect", + "agentId": "7b6ae717-cab4-4b7c-b4ea-ed61353ee6f4", + "displayName": "Dan Johnson", + "roleHint": "architect", + "capabilities": { + "hasDocker": false, + "hasPersistentDisk": false, + "maxTurnDurationSec": 1800, + "supportsLongLived": false, + "isolation": "process", + "egressPolicy": "scoped", + "minMemoryMB": 512 + }, + "description": "Architect — Dan Johnson (no prompt changes for SDLC; existing persona is strong)." + }, + { + "id": "developer", + "agentId": "0d913379-fa6c-4833-8c61-b39a94acec46", + "displayName": "David Black", + "roleHint": "developer", + "capabilities": { + "hasDocker": true, + "hasPersistentDisk": true, + "maxTurnDurationSec": 3600, + "supportsLongLived": false, + "isolation": "container", + "egressPolicy": "open", + "minMemoryMB": 2048, + "runtimeKindsAllowed": [ + "container", + "vm" + ] + }, + "description": "Developer — David Black (autonomous workflow-branch commit clause shipped 2026-06-11)." + }, + { + "id": "qa", + "agentId": "b49e743d-1004-406c-b832-db4098eaa8ac", + "displayName": "Maria Currie", + "roleHint": "qa", + "capabilities": { + "hasDocker": true, + "hasPersistentDisk": false, + "maxTurnDurationSec": 2700, + "supportsLongLived": false, + "isolation": "container", + "egressPolicy": "open", + "minMemoryMB": 1024, + "runtimeKindsAllowed": [ + "container", + "vm" + ] + }, + "description": "QA — Maria Currie (autonomous deploy/smoke/destroy/sign-off duties block shipped 2026-06-11)." + }, + { + "id": "devops", + "agentId": "7075eb90-38ec-4ab2-8159-c9eb8a2ff5a5", + "displayName": "William Smith", + "roleHint": "devops", + "capabilities": { + "hasDocker": false, + "hasPersistentDisk": false, + "maxTurnDurationSec": 1800, + "supportsLongLived": false, + "isolation": "process", + "egressPolicy": "scoped", + "minMemoryMB": 512 + }, + "description": "DevOps — William Smith (autonomous chain-continuation tail shipped 2026-06-11)." + } + ], + "transitions": [ + { + "id": "pm-to-architect", + "fromCharacterId": "pm", + "toCharacterId": "architect", + "escalationPolicy": "operator_review" + }, + { + "id": "architect-to-developer", + "fromCharacterId": "architect", + "toCharacterId": "developer", + "escalationPolicy": "operator_review" + }, + { + "id": "developer-to-qa", + "fromCharacterId": "developer", + "toCharacterId": "qa", + "escalationPolicy": "operator_review" + }, + { + "id": "qa-to-devops", + "fromCharacterId": "qa", + "toCharacterId": "devops", + "condition": "handoff.verdict == 'signoff'", + "escalationPolicy": "operator_review" + } + ], + "managementAgentId": "architect", + "enrichBudgetDefault": 0, + "storageMode": "shared", + "allowWorkflowTriggers": true, + "autonomyBudget": 3 +} \ No newline at end of file diff --git a/pkg/api/secrets/ciphers/encryption.go b/pkg/api/secrets/ciphers/encryption.go index 0424338d..7120c3b4 100644 --- a/pkg/api/secrets/ciphers/encryption.go +++ b/pkg/api/secrets/ciphers/encryption.go @@ -82,7 +82,10 @@ func PublicKeyToBytes(pub *rsa.PublicKey) ([]byte, error) { return pubBytes, nil } -// EncryptWithPublicRSAKey encrypts data with public key +// EncryptWithPublicRSAKey encrypts data with public key using RSA-OAEP + SHA-512. +// TODO: this uses SHA-512 (max plaintext 126 B for a 2048-bit key) while +// EncryptLargeString uses SHA-256 (max 190 B). Standardise in a future PR. +// Do NOT route payloads > 126 B through this function. func EncryptWithPublicRSAKey(msg []byte, pub *rsa.PublicKey) ([]byte, error) { hash := sha512.New() ciphertext, err := rsa.EncryptOAEP(hash, rand.Reader, pub, msg, nil) @@ -147,14 +150,26 @@ func ParsePublicKey(s string) (crypto.PublicKey, error) { func EncryptLargeString(key crypto.PublicKey, s string) ([]string, error) { var res []string if rsaKey, ok := key.(*rsa.PublicKey); ok { - chunks := lo.ChunkString(s, rsaKey.Size()/2) - res = make([]string, len(chunks)) - for idx, chunk := range chunks { - encryptedData, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, rsaKey, []byte(chunk), nil) + // RSA-OAEP with SHA-256: max plaintext per chunk = k - 2*hLen - 2 + // (RFC 8017 §7.1.1), where k = modulus bytes, hLen = sha256.Size = 32. + // For a 2048-bit key: 256 - 64 - 2 = 190 bytes. + // Chunk by BYTES (not runes) so multi-byte UTF-8 cannot overflow the limit. + maxPlain := rsaKey.Size() - 2*sha256.Size - 2 + if maxPlain <= 0 { + return nil, errors.Errorf("RSA key too small (%d bits) for OAEP-SHA256", rsaKey.Size()*8) + } + data := []byte(s) + res = make([]string, 0, (len(data)+maxPlain-1)/maxPlain) + for i := 0; i < len(data); i += maxPlain { + end := i + maxPlain + if end > len(data) { + end = len(data) + } + enc, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, rsaKey, data[i:end], nil) if err != nil { return nil, errors.Wrapf(err, "failed to encrypt secret") } - res[idx] = base64.StdEncoding.EncodeToString(encryptedData) + res = append(res, base64.StdEncoding.EncodeToString(enc)) } } else if ed25519Key, ok := key.(ed25519.PublicKey); ok { // For ed25519, use hybrid encryption with Curve25519 + ChaCha20-Poly1305 diff --git a/pkg/api/secrets/ciphers/encryption_test.go b/pkg/api/secrets/ciphers/encryption_test.go index af2d1772..42f11830 100644 --- a/pkg/api/secrets/ciphers/encryption_test.go +++ b/pkg/api/secrets/ciphers/encryption_test.go @@ -254,6 +254,86 @@ func TestEd25519DecryptionEdgeCases(t *testing.T) { }) } +// TestEncryptLargeStringUTF8 guards against the rune-vs-byte chunking bug where +// multi-byte UTF-8 characters could push a chunk over the OAEP-SHA256 byte limit +// (190 bytes for a 2048-bit key), causing "crypto/rsa: message too long". +func TestEncryptLargeStringUTF8(t *testing.T) { + RegisterTestingT(t) + + privKey, pubKey, err := GenerateKeyPair(2048) + Expect(err).To(BeNil()) + + t.Run("box-drawing flood: 120 runes × 3 bytes = 360 bytes", func(t *testing.T) { + // U+2500 BOX DRAWINGS LIGHT HORIZONTAL encodes as 3 UTF-8 bytes. + // 120 runes × 3 bytes = 360 bytes — far above the 190-byte OAEP limit. + // Old rune-based chunking (128 runes/chunk) would pass 360 bytes to EncryptOAEP. + s := strings.Repeat("─", 120) + chunks, err := EncryptLargeString(pubKey, s) + Expect(err).To(BeNil(), "encrypt must not fail for multi-byte UTF-8 input") + + decrypted, err := DecryptLargeString(privKey, chunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(s)) + }) + + t.Run("mixed multi-byte characters > 190 bytes", func(t *testing.T) { + // Mix of arrow (3 B), em-dash (3 B), Cyrillic (2 B), emoji (4 B) + s := strings.Repeat("→—яя🔑", 20) // each repeat ~14 bytes; 20× = ~280 bytes + chunks, err := EncryptLargeString(pubKey, s) + Expect(err).To(BeNil()) + + decrypted, err := DecryptLargeString(privKey, chunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(s)) + }) + + t.Run("exactly 190 ASCII bytes produces 1 chunk", func(t *testing.T) { + s := strings.Repeat("a", 190) + chunks, err := EncryptLargeString(pubKey, s) + Expect(err).To(BeNil()) + Expect(chunks).To(HaveLen(1)) + + decrypted, err := DecryptLargeString(privKey, chunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(s)) + }) + + t.Run("191 ASCII bytes splits into 2 chunks", func(t *testing.T) { + s := strings.Repeat("a", 191) + chunks, err := EncryptLargeString(pubKey, s) + Expect(err).To(BeNil()) + Expect(chunks).To(HaveLen(2)) + + decrypted, err := DecryptLargeString(privKey, chunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(s)) + }) + + t.Run("empty string round-trip", func(t *testing.T) { + chunks, err := EncryptLargeString(pubKey, "") + Expect(err).To(BeNil()) + Expect(chunks).To(BeEmpty()) + + decrypted, err := DecryptLargeString(privKey, chunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal("")) + }) + + t.Run("4096-bit key round-trip with large UTF-8 payload", func(t *testing.T) { + priv4096, pub4096, err := GenerateKeyPair(4096) + Expect(err).To(BeNil()) + + // maxPlain for 4096-bit key = 512 - 64 - 2 = 446 bytes + s := strings.Repeat("─", 300) // 300 × 3 bytes = 900 bytes → 3 chunks + chunks, err := EncryptLargeString(pub4096, s) + Expect(err).To(BeNil()) + + decrypted, err := DecryptLargeString(priv4096, chunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(s)) + }) +} + func TestKeyFormatting(t *testing.T) { RegisterTestingT(t)