diff --git a/deploy/windows/Register-PostgresBackupNasTask.ps1 b/deploy/windows/Register-PostgresBackupNasTask.ps1 new file mode 100644 index 0000000..c2ec51b --- /dev/null +++ b/deploy/windows/Register-PostgresBackupNasTask.ps1 @@ -0,0 +1,52 @@ +[CmdletBinding()] +param( + [string]$ScriptPath = '\\nas\MST\codex\assistent\deploy\windows\Sync-PostgresBackupToNas.ps1', + [string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\postgres-backup-key.dpapi.json", + [string]$NasKeyBackupPath = '\\nas\MST\codex\_backups\domovoy\keys\postgres-backup-key.dpapi.json', + [string]$TaskName = 'Domovoy PostgreSQL Off-host Backup' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$keyDirectory = Split-Path -Parent $KeyRecordPath +New-Item -ItemType Directory -Force -Path $keyDirectory | Out-Null +$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name + +if (-not (Test-Path -LiteralPath $KeyRecordPath) -and (Test-Path -LiteralPath $NasKeyBackupPath -PathType Leaf)) { + Copy-Item -LiteralPath $NasKeyBackupPath -Destination $KeyRecordPath +} + +if (-not (Test-Path -LiteralPath $KeyRecordPath)) { + $random = [byte[]]::new(48) + [Security.Cryptography.RandomNumberGenerator]::Fill($random) + $plain = [Convert]::ToBase64String($random) + try { + $protected = ConvertFrom-SecureString (ConvertTo-SecureString $plain -AsPlainText -Force) + $record = [ordered]@{ version = 1; project = 'domovoy'; purpose = 'postgres off-host backup'; protected_value = $protected; created_at_utc = [DateTime]::UtcNow.ToString('o') } + [IO.File]::WriteAllText($KeyRecordPath, ($record | ConvertTo-Json) + "`n", [Text.UTF8Encoding]::new($false)) + } + finally { + [Array]::Clear($random, 0, $random.Length) + $plain = $null + } +} +& icacls.exe $KeyRecordPath /inheritance:r /grant:r "${identity}:F" | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'DPAPI key ACL hardening failed' } + +$nasKeyDirectory = Split-Path -Parent $NasKeyBackupPath +New-Item -ItemType Directory -Force -Path $nasKeyDirectory | Out-Null +$nasKeyPartial = "$NasKeyBackupPath.$PID.partial" +Copy-Item -LiteralPath $KeyRecordPath -Destination $nasKeyPartial +Move-Item -LiteralPath $nasKeyPartial -Destination $NasKeyBackupPath -Force + +if (-not (Test-Path -LiteralPath $ScriptPath -PathType Leaf)) { throw "Scheduled sync script not found" } +$pwsh = (Get-Command pwsh.exe -ErrorAction Stop).Source +$arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$ScriptPath`"" +$action = New-ScheduledTaskAction -Execute $pwsh -Argument $arguments +$daily = New-ScheduledTaskTrigger -Daily -At '04:00' +$logon = New-ScheduledTaskTrigger -AtLogOn -User $identity +$principal = New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 2) +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger @($daily, $logon) -Principal $principal -Settings $settings -Description 'Encrypt and replicate the latest Domovoy PostgreSQL backup to the private NAS.' -Force | Out-Null +Write-Output "Scheduled task registered: $TaskName" diff --git a/deploy/windows/Sync-PostgresBackupToNas.ps1 b/deploy/windows/Sync-PostgresBackupToNas.ps1 new file mode 100644 index 0000000..12b1801 --- /dev/null +++ b/deploy/windows/Sync-PostgresBackupToNas.ps1 @@ -0,0 +1,115 @@ +[CmdletBinding()] +param( + [string]$SshHost = 'docker', + [string]$RemoteBackupRoot = '/home/m/assistant-backups/postgres', + [string]$NasBackupRoot = '\\nas\MST\codex\_backups\domovoy\postgres', + [string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\postgres-backup-key.dpapi.json", + [string]$GpgPath = 'gpg.exe' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-SourceHashes([string]$Directory) { + $allowed = @('assistant.dump', 'globals.sql', 'database.name', 'owner.name') + $expected = @{} + foreach ($line in Get-Content -LiteralPath (Join-Path $Directory 'SHA256SUMS')) { + if ($line -notmatch '^([0-9a-f]{64}) ([A-Za-z0-9.]+)$' -or $matches[2] -notin $allowed) { + throw "Invalid SHA256SUMS entry" + } + $expected[$matches[2]] = $matches[1] + } + foreach ($name in $allowed) { + if (-not $expected.ContainsKey($name)) { throw "Missing SHA256SUMS entry: $name" } + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $Directory $name)).Hash.ToLowerInvariant() + if ($actual -ne $expected[$name]) { throw "Source hash mismatch: $name" } + } +} + +function Get-RecoveryPassphrase([string]$Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "DPAPI recovery key is missing" } + $record = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($record.version -ne 1 -or [string]::IsNullOrWhiteSpace($record.protected_value)) { throw "Invalid DPAPI recovery key record" } + $secure = ConvertTo-SecureString -String $record.protected_value + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) + try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } + finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } +} + +$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$work = Join-Path $tempRoot ("domovoy-backup-sync-" + [guid]::NewGuid().ToString('N')) +if (-not ([IO.Path]::GetFullPath($work)).StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe temporary path' } +New-Item -ItemType Directory -Path $work | Out-Null +$passphrase = $null +$cipherPartial = $null +$manifestPartial = $null + +try { + $latest = ((& ssh.exe $SshHost "find '$RemoteBackupRoot' -mindepth 1 -maxdepth 1 -type d ! -name '.*' -printf '%f\n' | sort | tail -n1") -join '').Trim() + if ($LASTEXITCODE -ne 0 -or $latest -notmatch '^\d{8}T\d{6}Z$') { throw 'No valid completed production backup found' } + + New-Item -ItemType Directory -Force -Path $NasBackupRoot | Out-Null + $cipherPath = Join-Path $NasBackupRoot "$latest.tar.gz.gpg" + $manifestPath = Join-Path $NasBackupRoot "$latest.json" + if ((Test-Path -LiteralPath $cipherPath) -xor (Test-Path -LiteralPath $manifestPath)) { + # Neither lone file is a completed backup. Remove only this exact incomplete pair before retry. + Remove-Item -LiteralPath $cipherPath, $manifestPath -Force -ErrorAction SilentlyContinue + } + if ((Test-Path -LiteralPath $cipherPath) -or (Test-Path -LiteralPath $manifestPath)) { + if (-not ((Test-Path -LiteralPath $cipherPath -PathType Leaf) -and (Test-Path -LiteralPath $manifestPath -PathType Leaf))) { throw 'Incomplete existing NAS artifact' } + $existing = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + $actualCipherHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $cipherPath).Hash.ToLowerInvariant() + if ($existing.source_backup -ne $latest -or $existing.ciphertext_sha256 -ne $actualCipherHash) { throw 'Existing NAS artifact does not match its manifest' } + Write-Output "Off-host backup already current: $latest" + return + } + + & scp.exe -q -r "${SshHost}:${RemoteBackupRoot}/${latest}" $work + if ($LASTEXITCODE -ne 0) { throw 'SCP backup copy failed' } + $sourceDirectory = Join-Path $work $latest + Assert-SourceHashes $sourceDirectory + + $archive = Join-Path $work "$latest.tar.gz" + & tar.exe -czf $archive -C $work $latest + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $archive -PathType Leaf)) { throw 'Archive creation failed' } + $archiveHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() + + $passphrase = Get-RecoveryPassphrase $KeyRecordPath + $passphraseFile = Join-Path $work 'passphrase.txt' + [IO.File]::WriteAllText($passphraseFile, $passphrase, [Text.UTF8Encoding]::new($false)) + & icacls.exe $work /inheritance:r /grant:r "$([Security.Principal.WindowsIdentity]::GetCurrent().Name):(OI)(CI)F" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Temporary ACL hardening failed' } + + $cipherPartial = Join-Path $NasBackupRoot (".$latest.$PID.gpg.partial") + $manifestPartial = Join-Path $NasBackupRoot (".$latest.$PID.json.partial") + & $GpgPath --batch --yes --pinentry-mode loopback --passphrase-file $passphraseFile --symmetric --cipher-algo AES256 --s2k-digest-algo SHA512 --s2k-cipher-algo AES256 --s2k-count 65011712 --compress-algo none --output $cipherPartial $archive + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $cipherPartial -PathType Leaf)) { throw 'GnuPG encryption failed' } + + $roundTrip = Join-Path $work 'roundtrip.tar.gz' + & $GpgPath --batch --yes --pinentry-mode loopback --passphrase-file $passphraseFile --decrypt --output $roundTrip $cipherPartial + if ($LASTEXITCODE -ne 0) { throw 'GnuPG round-trip decryption failed' } + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $roundTrip).Hash.ToLowerInvariant() -ne $archiveHash) { throw 'Encrypted round-trip archive hash mismatch' } + + $manifest = [ordered]@{ + version = 1 + project = 'domovoy' + source_host = 'docker.cin.su' + source_backup = $latest + source_sha256sums_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $sourceDirectory 'SHA256SUMS')).Hash.ToLowerInvariant() + archive_sha256 = $archiveHash + ciphertext_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $cipherPartial).Hash.ToLowerInvariant() + encryption = 'GnuPG symmetric AES256, SHA512 S2K' + created_at_utc = [DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($manifestPartial, ($manifest | ConvertTo-Json) + "`n", [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $cipherPartial -Destination $cipherPath + Move-Item -LiteralPath $manifestPartial -Destination $manifestPath + Write-Output "Off-host backup created: $latest" +} +finally { + $passphrase = $null + foreach ($partial in @($cipherPartial, $manifestPartial)) { + if ($partial -and (Test-Path -LiteralPath $partial)) { Remove-Item -LiteralPath $partial -Force } + } + if (Test-Path -LiteralPath $work) { Remove-Item -LiteralPath $work -Recurse -Force } +} diff --git a/deploy/windows/Test-PostgresBackupFromNas.ps1 b/deploy/windows/Test-PostgresBackupFromNas.ps1 new file mode 100644 index 0000000..e603789 --- /dev/null +++ b/deploy/windows/Test-PostgresBackupFromNas.ps1 @@ -0,0 +1,75 @@ +[CmdletBinding()] +param( + [string]$NasBackupRoot = '\\nas\MST\codex\_backups\domovoy\postgres', + [string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\postgres-backup-key.dpapi.json", + [string]$GpgPath = 'gpg.exe', + [string]$SshHost = 'docker', + [switch]$VerifyRemoteRestore +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Get-RecoveryPassphrase([string]$Path) { + $record = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($record.version -ne 1 -or [string]::IsNullOrWhiteSpace($record.protected_value)) { throw 'Invalid DPAPI recovery key record' } + $secure = ConvertTo-SecureString -String $record.protected_value + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) + try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } + finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } +} + +$manifestFile = Get-ChildItem -LiteralPath $NasBackupRoot -Filter '*.json' -File | Sort-Object BaseName | Select-Object -Last 1 +if (-not $manifestFile -or $manifestFile.BaseName -notmatch '^\d{8}T\d{6}Z$') { throw 'No valid NAS backup manifest found' } +$manifest = Get-Content -LiteralPath $manifestFile.FullName -Raw | ConvertFrom-Json +$latest = $manifestFile.BaseName +$cipherPath = Join-Path $NasBackupRoot "$latest.tar.gz.gpg" +if ((Get-FileHash -Algorithm SHA256 -LiteralPath $cipherPath).Hash.ToLowerInvariant() -ne $manifest.ciphertext_sha256) { throw 'NAS ciphertext hash mismatch' } + +$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$work = Join-Path $tempRoot ("domovoy-backup-test-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $work | Out-Null +$passphrase = $null +$remoteCheck = $null + +try { + & icacls.exe $work /inheritance:r /grant:r "$([Security.Principal.WindowsIdentity]::GetCurrent().Name):(OI)(CI)F" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Temporary ACL hardening failed' } + $passphrase = Get-RecoveryPassphrase $KeyRecordPath + $passphraseFile = Join-Path $work 'passphrase.txt' + [IO.File]::WriteAllText($passphraseFile, $passphrase, [Text.UTF8Encoding]::new($false)) + $archive = Join-Path $work "$latest.tar.gz" + & $GpgPath --batch --yes --pinentry-mode loopback --passphrase-file $passphraseFile --decrypt --output $archive $cipherPath + if ($LASTEXITCODE -ne 0) { throw 'NAS backup decryption failed' } + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() -ne $manifest.archive_sha256) { throw 'Decrypted archive hash mismatch' } + & tar.exe -xzf $archive -C $work + if ($LASTEXITCODE -ne 0) { throw 'Decrypted archive extraction failed' } + $sourceDirectory = Join-Path $work $latest + Push-Location $sourceDirectory + try { + foreach ($line in Get-Content -LiteralPath 'SHA256SUMS') { + if ($line -notmatch '^([0-9a-f]{64}) ([A-Za-z0-9.]+)$') { throw 'Invalid decrypted SHA256SUMS' } + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $matches[2]).Hash.ToLowerInvariant() -ne $matches[1]) { throw "Decrypted file hash mismatch: $($matches[2])" } + } + } + finally { Pop-Location } + + if ($VerifyRemoteRestore) { + $remoteCheck = "/home/m/assistant-backups/offhost-restore-check/$latest-$([guid]::NewGuid().ToString('N'))" + if ($remoteCheck -notmatch '^/home/m/assistant-backups/offhost-restore-check/\d{8}T\d{6}Z-[0-9a-f]{32}$') { throw 'Unsafe remote restore path' } + & ssh.exe $SshHost "mkdir -p '$remoteCheck'" + if ($LASTEXITCODE -ne 0) { throw 'Remote restore-check directory creation failed' } + & scp.exe -q -r "$sourceDirectory\*" "${SshHost}:${remoteCheck}/" + if ($LASTEXITCODE -ne 0) { throw 'Remote restore-check copy failed' } + & ssh.exe $SshHost "/home/m/assistant-ops/verify-postgres-restore.sh '$remoteCheck'" + if ($LASTEXITCODE -ne 0) { throw 'Remote PostgreSQL restore check failed' } + } + Write-Output "Off-host backup verified: $latest" +} +finally { + if ($remoteCheck -and $remoteCheck -match '^/home/m/assistant-backups/offhost-restore-check/\d{8}T\d{6}Z-[0-9a-f]{32}$') { + & ssh.exe $SshHost "rm -rf -- '$remoteCheck'" | Out-Null + } + $passphrase = $null + if (Test-Path -LiteralPath $work) { Remove-Item -LiteralPath $work -Recurse -Force } +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 26c1fbc..cbeb08e 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000036-creator-secret-files.json", - "task_id": "DEV-000036", + "task_file": "dev/tasks/DEV-000037-encrypted-offhost-backup.json", + "task_id": "DEV-000037", "task_revision": 1 } diff --git a/dev/tasks/DEV-000037-encrypted-offhost-backup.json b/dev/tasks/DEV-000037-encrypted-offhost-backup.json new file mode 100644 index 0000000..73bbaab --- /dev/null +++ b/dev/tasks/DEV-000037-encrypted-offhost-backup.json @@ -0,0 +1,85 @@ +{ + "task_id": "DEV-000037", + "task_revision": 1, + "architecture_revision": "ADR-0004", + "base_repo_revision": "03dde8c1961f214fbb5ebdefa131eb38fd0bef57", + "risk_tier": "E", + "intent": "Replicate verified production PostgreSQL backups to the private NAS as authenticated encrypted artifacts, with a DPAPI-protected recovery key and tested restore path, without using docker-test.", + "requirement_ids": ["CONSTITUTION-1.3.36", "USER-DOCKER-PROD-ONLY"], + "allowed_change_scope": { + "modules": [], + "paths": [ + "deploy/windows/Sync-PostgresBackupToNas.ps1", + "deploy/windows/Test-PostgresBackupFromNas.ps1", + "deploy/windows/Register-PostgresBackupNasTask.ps1", + "docs/CREDENTIALS_AND_SECRETS.md", + "docs/DEPLOYMENT.md", + "dev/ACTIVE_TASK.json", + "dev/tasks/DEV-000037-encrypted-offhost-backup.json" + ] + }, + "forbidden_change_scope": { + "paths": [ + "architecture/**", + "ci/**", + ".gitea/**", + "cmd/**", + "contracts/**", + "modules/**", + "database/**", + "security/**", + "deploy/docker-compose*.yml" + ] + }, + "allowed_architecture_delta": { + "new_cross_module_dependencies": 0, + "new_canonical_owners": 0, + "new_privileges": 0, + "new_operator_dependencies": 1 + }, + "dependencies": { + "operator": ["GnuPG 2.4+ already installed on the authorized Windows backup workstation"] + }, + "contracts": { + "consume": ["DEV-000035 verified PostgreSQL backup directory", "SSH key to docker.cin.su", "authenticated NAS session", "Windows DPAPI CurrentUser"], + "may_change": [] + }, + "semantic_owner": "none; disaster-recovery transport only", + "canonical_state": ["none; backup artifacts are exported recovery copies"], + "state_transitions": ["verified local backup -> encrypted off-host copy -> decrypt-and-restore verified copy"], + "side_effects_and_privileges": ["reads completed production backup artifacts over SSH", "writes encrypted artifacts to the private NAS", "stores a random recovery passphrase as CurrentUser DPAPI ciphertext outside Git", "registers a current-user scheduled task"], + "persistence_and_migrations": ["no database migration", "encrypted .gpg artifact plus non-secret JSON manifest on NAS", "no automatic deletion or retention policy"], + "failure_retry_restart": ["NAS completion is indicated only after ciphertext and manifest are atomically renamed", "existing matching artifacts are idempotently retained", "temporary plaintext is confined to a validated per-run Windows temp directory and removed on exit", "scheduled retries run at logon and daily", "failed remote restore checks remove only their generated temporary directory"], + "assumptions": [ + "The authorized Windows user retains access to both the SSH alias docker and the private NAS share", + "GnuPG 2.4+ remains installed on that workstation", + "The DPAPI profile and NAS are independent from the production Docker host", + "docker-test remains prohibited" + ], + "acceptance_scenarios": [ + "the latest completed production backup is copied and source hashes are checked before encryption", + "NAS contains ciphertext and a non-secret manifest but no plaintext PostgreSQL dump", + "the recovery passphrase is random and stored only as DPAPI ciphertext outside the repository", + "ciphertext decrypts and reproduces the source archive hash", + "the decrypted off-host copy passes SHA-256 checks and an isolated PostgreSQL restore on docker.cin.su", + "daily and logon triggers are registered for the current user", + "no backup is automatically deleted", + "exact diff validation observes only declared paths" + ], + "required_tests": [ + "PowerShell parser", + "architecture preflight", + "exact diff scope", + "source SHA-256 validation", + "GnuPG encrypt/decrypt round trip", + "NAS ciphertext hash validation", + "isolated restore from decrypted NAS artifact", + "scheduled task registration and manual invocation", + "production health", + "CI" + ], + "terminal_gap_states": [ + "BLOCKED_BY_MISSING_CAPABILITY", + "BLOCKED_BY_CONFLICT" + ] +} diff --git a/docs/CREDENTIALS_AND_SECRETS.md b/docs/CREDENTIALS_AND_SECRETS.md index 5295867..c822345 100644 --- a/docs/CREDENTIALS_AND_SECRETS.md +++ b/docs/CREDENTIALS_AND_SECRETS.md @@ -22,3 +22,5 @@ Do not guess Portainer credentials and do not search other project source trees Each privileged runtime gets only its required secret/capability bindings. `commit-runtime` is intentionally isolated from the general Core Runtime. 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. + +The PostgreSQL off-host backup passphrase is generated randomly and stored as CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\postgres-backup-key.dpapi.json`. An atomic copy of that DPAPI ciphertext, never the plaintext passphrase, is retained at `\\nas\MST\codex\_backups\domovoy\keys\postgres-backup-key.dpapi.json`. The passphrase is never stored in Git, the production Docker host, a scheduled-task argument, or NAS plaintext. The NAS backup directory receives only GnuPG AES-256 ciphertext and a non-secret integrity manifest. Recovery therefore requires both the NAS artifacts and the authorized Windows DPAPI profile. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index c1dd9de..001e9b2 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -27,3 +27,9 @@ Production backups run only on `docker.cin.su`; this project must not use the sh `deploy/verify-postgres-restore.sh ` verifies all hashes and restores into an isolated PostgreSQL 18.6 container backed by tmpfs. It never mounts or writes `assistant_assistant_pg`, and removes its temporary container on exit. A backup is operationally accepted only after this restore check passes. The production user crontab installs the single entry from `deploy/cron/assistant-postgres-backup.crontab`. It runs the backup and restore check daily at 00:15 UTC (03:15 Europe/Moscow), survives host reboot without a persistent login session, and writes a private operational log beside the backups. + +## Encrypted off-host backup + +`deploy/windows/Sync-PostgresBackupToNas.ps1` copies only a completed, hash-verified production backup over SSH, archives it locally, encrypts it with GnuPG AES-256, verifies an immediate decrypt round trip, and atomically publishes ciphertext plus a non-secret manifest under `\\nas\MST\codex\_backups\domovoy\postgres`. Temporary plaintext is removed on exit. The script is idempotent and never deletes existing backups. + +`deploy/windows/Test-PostgresBackupFromNas.ps1 -VerifyRemoteRestore` verifies the NAS ciphertext hash, decrypts it with the DPAPI-protected recovery key, checks every source hash, and performs a full isolated PostgreSQL restore on `docker.cin.su`. `deploy/windows/Register-PostgresBackupNasTask.ps1` generates the key when absent, atomically backs up only its DPAPI ciphertext to the NAS, and registers current-user daily and logon triggers. The task intentionally does not use `docker-test`.