Playbook: SOPS File Corruption on Windows (CRLF / BOM)
Systematic fix — already in place
.gitattributes enforces text eol=lf on secrets/*.env.sops. Git normalizes any
CRLF to LF on every git add, so even if a Windows write produces CRLF, it is fixed
before the commit lands. This is the primary protection — it works regardless of write method or OS.
secrets/*.env.sops text eol=lfThe previous entry was -text (binary) which disabled normalization — that was wrong.
SOPS files are YAML-format text that requires LF. The fix was applied 2026-06-24.
The remaining defense-in-depth rules (use WriteAllText($false), run canary decrypt)
protect against catching corruption before git add rather than relying solely on git.
.gitattributes eol=lfdoes NOT catch every CRLF case — see Failure mode 2 below. It normalizes the line endings of the.sopsfile structure. It cannot touch a\rthat was inside the plaintext at encryption time — that byte is encrypted into the value’s ciphertext, so the.sopsfile has zero CR bytes yet every decrypted value ends in\r. The canary decrypt also passes (no parse error). This is silent.
Failure mode 2: CRLF baked into encrypted VALUES (silent — no parse error)
Discovered on #2238 (WEBHOOK_SECRET 401). Distinct from Failure mode 1 above:
| Failure mode 1 (CR in file) | Failure mode 2 (CR in values) | |
|---|---|---|
Where the \r lives | .sops file line structure | inside each encrypted value |
CR bytes in .sops file | > 0 | 0 |
sops --decrypt | errors: parsing time "...Z\x0d" | succeeds — no error |
.gitattributes eol=lf catches it | yes | no |
| Symptom | decrypt/deploy fails loudly | secret silently mismatches downstream (e.g. 401) |
Root cause: the plaintext fed to sops --encrypt had CRLF line endings (Windows temp
edit file, or a CRLF source .env). SOPS encrypts each value verbatim, so a trailing \r
is baked into every value. When the value is later piped to a consumer
(printf '%s' "$val" | wrangler secret put, Docker env, etc.) the \r rides along and the
remote value no longer byte-matches what the other side sends → silent auth failure.
Detect (Linux — the only reliable check)
# Count VALUES (not file bytes) ending in CR. ANY > 0 = corruption, even if the file is clean.
sops -d --input-type dotenv --output-type dotenv secrets/whatsup.env.sops | grep -acP '\r$'
# List the affected key NAMES (no values printed):
sops -d --input-type dotenv --output-type dotenv secrets/whatsup.env.sops \
| grep -aP '\r$' | sed -E 's/=.*/ <-- trailing CR/'Fix (Linux — strip CR from plaintext, re-encrypt with the file’s recipients)
sops 3.9.x loads .sops.yaml by CWD and refuses --age/SOPS_AGE_RECIPIENTS when a
config is present but the input path doesn’t match a creation rule. Two ways around it:
export SOPS_AGE_KEY_FILE=/home/claude-runner/.age/p24-infra-keys.txt # worker path
umask 077
TB=$(mktemp); TA=$(mktemp); ENC=$(mktemp)
sops -d --input-type dotenv --output-type dotenv secrets/whatsup.env.sops > "$TB"
tr -d '\r' < "$TB" > "$TA" # strip ONLY \r — never touch \n or content
# Recipients = the 4 age keys from .sops.yaml (developer + CI + vps-i1 + bms-4 runner).
RECIPS="age1...dev,age1...ci,age1...i1,age1...bms4"
( cd /tmp && SOPS_AGE_RECIPIENTS="$RECIPS" sops --encrypt \
--input-type dotenv --output-type dotenv "$TA" > "$ENC" ) # run from /tmp: no .sops.yaml
# Verify BEFORE moving into place: 0 CR values, key count unchanged, all 4 recipients present.
sops -d --input-type dotenv --output-type dotenv "$ENC" | grep -acP '\r$' # expect 0
mv "$ENC" secrets/whatsup.env.sops
shred -u "$TB" "$TA" # destroy plaintext tempsNever sops --encrypt ... > secrets/whatsup.env.sops directly — the shell truncates the
target file before sops runs, so a sops failure leaves the file empty (recover with
git checkout -- secrets/whatsup.env.sops). Always encrypt to a temp, verify, then mv.
After fixing SOPS, re-push every consumer that was deployed from the corrupted value
(e.g. wrangler secret put for the CF Worker) — fixing SOPS alone does not update an
already-deployed secret.
What triggers this problem (historical / if gitattributes is ever absent)
Any time a SOPS-encrypted .env.sops file is written on Windows using PowerShell’s
native file-writing cmdlets (> redirect, Out-File, Set-Content, Add-Content)
AND the .gitattributes text eol=lf rule is not in place.
>andOut-Filewrite CRLF line endings. SOPS stores a timestamp in the encrypted header; when it re-reads that timestamp the\rcauses:Error: parsing time "2026-06-24T17:49:44Z\x0d": extra text: "\x0d"Out-File -Encoding utf8(PowerShell 5.1 default) also writes a BOM (\xef\xbb\xbf). When the BOM lands at the start of a plaintext env file that is then encrypted, the first key name gets a hidden BOM prefix and is never matched bygrepor shell sourcing.
Both bugs are invisible in text editors. The file looks correct but SOPS (or Docker env sourcing) silently fails.
How to confirm it
# CRLF symptom -- parse error
sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops 2>&1
# Output contains: parsing time "...Z\x0d": extra text: "\x0d"
# MAC failure symptom -- file was modified after encryption (different corruption)
# Output contains: cipher: message authentication failed
# Check for CRLF in a SOPS file
$bytes = [System.IO.File]::ReadAllBytes("C:\code_2026\p24-infra\secrets\monitoring.env.sops")
$crCount = ($bytes | Where-Object { $_ -eq 13 }).Count
Write-Host "CR bytes: $crCount" # anything > 0 = CRLF corruptedStep-by-step fix
1. Fetch the corrupted file from the branch
git fetch origin <branch-name>
git show origin/<branch-name>:secrets/monitoring.env.sops | Set-Content $env:TEMP\monitoring-test.env.sops -Encoding UTF82. Strip CR characters and test decryption
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$testPath = "$env:TEMP\monitoring-test.env.sops"
# Get file from branch and strip CR
$rawBytes = git show origin/<branch-name>:secrets/monitoring.env.sops
$content = ($rawBytes -join "`n").Replace("`r", "")
[System.IO.File]::WriteAllText($testPath, $content, [System.Text.UTF8Encoding]::new($false))
# Verify decryption works
$result = sops --decrypt --input-type dotenv --output-type dotenv $testPath 2>&1
if ($LASTEXITCODE -eq 0) { Write-Host "Decrypt OK after CR strip" }
else { Write-Host "Still failing: $result" }3. If decrypt succeeds — fix the file in a worktree and push
# Create worktree for the branch
git worktree add C:\tmp\fix-sops-crlf <branch-name>
# Fix all SOPS files that have CRLF
foreach ($file in Get-ChildItem "C:\tmp\fix-sops-crlf\secrets\*.env.sops") {
$raw = git -C C:\code_2026\p24-infra show "origin/<branch-name>:secrets/$($file.Name)"
$fixed = ($raw -join "`n").Replace("`r", "")
[System.IO.File]::WriteAllText($file.FullName, $fixed, [System.Text.UTF8Encoding]::new($false))
Write-Host "Fixed $($file.Name)"
}
# Verify all decrypt cleanly
foreach ($file in Get-ChildItem "C:\tmp\fix-sops-crlf\secrets\*.env.sops") {
sops --decrypt --input-type dotenv --output-type dotenv $file.FullName | Out-Null
if ($LASTEXITCODE -eq 0) { Write-Host "OK: $($file.Name)" }
else { Write-Host "STILL BROKEN: $($file.Name)" }
}
# Commit and push
Set-Location C:\tmp\fix-sops-crlf
git add secrets\*.env.sops
git commit -m "fix: strip CRLF corruption from SOPS files (re-encode LF-only)"
git push origin <branch-name>4. If MAC failure (not just CRLF)
MAC failure means the file was modified after encryption — the encrypted content no longer matches the MAC. CRLF-stripping will not fix this; the file must be reconstructed.
Recovery source — in priority order:
- Live server
.envfile (already deployed, same as what was encrypted):- vps-i1:
ssh root@217.154.82.162 'cat /opt/p24-infra/monitoring/.env' - bms-4:
ssh root@54.36.123.110 'cat /opt/p24-infra/bms-4/.env'
- vps-i1:
.env.localon the developer workstation (may be stale)- On-server
.env.bakfiles
Reconstruct from live server:
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# Capture live env (pipe through a filter -- never display in chat)
$liveEnv = ssh root@217.154.82.162 'cat /opt/p24-infra/monitoring/.env'
$plainText = ($liveEnv -join "`n") + "`n"
[System.IO.File]::WriteAllText("$env:TEMP\monitoring-reconstruct.env", $plainText, [System.Text.UTF8Encoding]::new($false))
# Copy to secrets/ (must match path_regex for SOPS to pick up the right age keys)
Copy-Item "$env:TEMP\monitoring-reconstruct.env" "C:\code_2026\p24-infra\secrets\monitoring-reconstruct.env.sops"
$enc = sops --encrypt --input-type dotenv --output-type dotenv "C:\code_2026\p24-infra\secrets\monitoring-reconstruct.env.sops"
[System.IO.File]::WriteAllText("C:\code_2026\p24-infra\secrets\monitoring.env.sops",
($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
# Canary
sops --decrypt --input-type dotenv --output-type dotenv "C:\code_2026\p24-infra\secrets\monitoring.env.sops" | Out-Null
if ($LASTEXITCODE -eq 0) { Write-Host "Reconstructed and verified OK" }
[System.IO.File]::Delete("C:\code_2026\p24-infra\secrets\monitoring-reconstruct.env.sops")
[System.IO.File]::Delete("$env:TEMP\monitoring-reconstruct.env")Prevention — ALWAYS use this write pattern on Windows
Preferred: sops --output bypasses PowerShell entirely (no string capture)
# BEST: SOPS writes the encrypted file itself in binary mode — no PowerShell string pipeline
$TEMP_PLAIN = "secrets\monitoring-edit.env.sops" # plaintext temp (matches path_regex)
$TEMP_ENC = "secrets\monitoring-enc-tmp.env.sops" # encrypted temp before moving into place
# Write plaintext with WriteAllText, then encrypt via --output (not stdout capture)
[System.IO.File]::WriteAllText($TEMP_PLAIN, $content, [System.Text.UTF8Encoding]::new($false))
sops --encrypt --input-type dotenv --output-type dotenv --output $TEMP_ENC $TEMP_PLAIN
# Canary on the temp, then atomic move — production file is never touched if encrypt fails
sops --decrypt --input-type dotenv --output-type dotenv $TEMP_ENC | Out-Null
if ($LASTEXITCODE -ne 0) { Remove-Item $TEMP_PLAIN, $TEMP_ENC -Force; throw "SOPS corrupt" }
Move-Item $TEMP_ENC "secrets\monitoring.env.sops" -Force
Remove-Item $TEMP_PLAIN -ForceFallback: if stdout capture is unavoidable, use WriteAllText on the captured output
# CORRECT: LF-only, no BOM
[System.IO.File]::WriteAllText($path, ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
# WRONG: adds CRLF
$enc > secrets\monitoring.env.sops
Out-File -FilePath secrets\monitoring.env.sops
# WRONG: adds BOM (corrupts first key name in plaintext files)
Out-File -FilePath secrets\monitoring.env.sops -Encoding utf8
Set-Content secrets\monitoring.env.sops
# ALSO WRONG for plaintext temp files:
sops --decrypt ... | Out-File secrets\monitoring-edit.env.sops -Encoding utf8
Add-Content secrets\monitoring-edit.env.sops "NEW_KEY=value" # CRLF
Set-Content secrets\monitoring-edit.env.sops $lines # BOMMandatory canary after every SOPS write:
sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt -- do NOT git add" }
Write-Host "Canary OK"Escalation path
If the fix doesn’t work after two attempts:
- Discord alert via
P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL - GitHub issue in
radieu/p24-infrawith labelhuman-action - Fall back to on-server
.env.bakas the authoritative source for reconstruction