diff --git a/scripts/migrate_nextcloud_backup_account.py b/scripts/migrate_nextcloud_backup_account.py
new file mode 100644
index 0000000..2b8735e
--- /dev/null
+++ b/scripts/migrate_nextcloud_backup_account.py
@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""Attended Backup-account cutover. Provider credentials never leave captured memory."""
+import argparse
+import base64
+import json
+import os
+from pathlib import Path
+import re
+import secrets
+import subprocess
+import tempfile
+import urllib.error
+import urllib.parse
+import urllib.request
+import xml.etree.ElementTree as ET
+
+from state_hub_preflight_lane import ROOT, LaneError, bao, data
+
+HOST = 'https://nx4069.your-storageshare.de'
+OPERATOR_PATH = 'operators/data/nextcloud/backup'
+LANE = 'platform/data/workloads/railiance/backup/offsite-lane'
+FOLDER = '/railiance-backups'
+LABEL = 'railiance-backup-upload-only'
+QUOTA = 10 * 1024**3
+PREFIXES = ['forgejo', 'core-apps-pg', 'core-gitea-db', 'core-net-kingdom-pg',
+ 'core-state-hub-db', 'r01-forgejo-db', 'r01-platform-pg',
+ 'r01-net-kingdom-pg', 'r01-state-hub-db', 'r01-user-engine-pg']
+
+
+def require(ok, message):
+ if not ok:
+ raise LaneError(message)
+
+
+class NoRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, *args, **kwargs):
+ return None
+
+
+def request(url, method='GET', body=None, auth=None, headers=None):
+ require(url.startswith(HOST + '/'), 'unapproved_provider_origin')
+ headers = dict(headers or {})
+ if auth:
+ headers['Authorization'] = 'Basic ' + base64.b64encode((auth[0] + ':' + auth[1]).encode()).decode()
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
+ try:
+ with urllib.request.build_opener(NoRedirect()).open(req, timeout=60) as response:
+ return response.status, response.read()
+ except urllib.error.HTTPError as error:
+ return error.code, b''
+
+
+def quota(auth):
+ body = b''
+ status, result = request(HOST + '/remote.php/dav/files/Backup/', 'PROPFIND', body, auth,
+ {'Depth':'0','Content-Type':'application/xml'})
+ require(status == 207, 'backup_account_auth_or_quota_failed')
+ root = ET.fromstring(result)
+ available = int(root.findtext('.//{DAV:}quota-available-bytes'))
+ used = int(root.findtext('.//{DAV:}quota-used-bytes'))
+ require(available >= 0 and used >= 0 and available + used == QUOTA, 'quota_contract_mismatch')
+ return {'quota_bytes': QUOTA, 'used_bytes': used, 'available_bytes': available}
+
+
+def ocs(auth, suffix='', method='GET', fields=None):
+ body = urllib.parse.urlencode(fields).encode() if fields is not None else None
+ code, content = request(HOST + '/ocs/v2.php/apps/files_sharing/api/v1/shares' + suffix,
+ method, body, auth, {'OCS-APIRequest':'true','Accept':'application/json',
+ 'Content-Type':'application/x-www-form-urlencoded'})
+ require(code in (200, 201), 'share_api_failed')
+ response = json.loads(content)['ocs']
+ require(response['meta']['statuscode'] in (100, 200), 'share_operation_rejected')
+ return response['data']
+
+
+def subprocess_bytes(argv, value):
+ result = subprocess.run(argv, input=value, capture_output=True, timeout=60)
+ require(result.returncode == 0, 'protected_crypto_failed')
+ return result.stdout
+
+
+def run(args, receipt):
+ identity = data(bao(['token', 'lookup', '-format=json']))['data']
+ require('platform-admin' in identity['policies'] and 'root' not in identity['policies'], 'attended_platform_admin_required')
+ entry = data(bao(['read', '-format=json', OPERATOR_PATH]))['data']['data']
+ require(entry['BACKUP_USERNAME'] == 'Backup' and bool(entry['BACKUP_PASSWORD']), 'operator_account_contract_mismatch')
+ auth = (entry['BACKUP_USERNAME'], entry['BACKUP_PASSWORD'])
+ receipt['quota_before'] = quota(auth)
+ old = data(bao(['read', '-format=json', LANE]))['data']
+ expected_version = old['metadata']['version']
+ if args.expected_version is not None:
+ require(expected_version == args.expected_version, 'kv_version_changed')
+ receipt['previous_kv_version'] = expected_version
+ values = old['data']
+ require(bool(values.get('AGE_PRIVATE_KEY')), 'recovery_escrow_missing')
+ recipient = re.search(r'age1[0-9a-z]+', (ROOT / 'lib/railiance-backup-common.sh').read_text()).group(0)
+ owner_root = HOST + '/remote.php/dav/files/Backup' + FOLDER
+ for folder in [FOLDER] + [FOLDER + '/' + name for name in PREFIXES]:
+ code, _ = request(HOST + '/remote.php/dav/files/Backup' + folder, 'MKCOL', auth=auth)
+ require(code in (201, 405), 'backup_folder_creation_failed')
+ shares = ocs(auth, '?path=' + urllib.parse.quote(FOLDER, safe=''))
+ matches = [s for s in shares if s.get('label') == LABEL]
+ require(len(matches) <= 1, 'ambiguous_existing_share')
+ created = False
+ if matches:
+ share = matches[0]
+ else:
+ share = ocs(auth, method='POST', fields={'path':FOLDER,'shareType':3,'permissions':4,'publicUpload':'true','label':LABEL,'sendMail':'false'})
+ created = True
+ share_id = str(share['id'])
+ receipt['share_id'] = share_id
+ if share.get('uid_owner') != 'Backup' or share.get('path') != FOLDER or int(share['permissions']) != 4:
+ if created:
+ ocs(auth, '/' + share_id, 'DELETE')
+ raise LaneError('create_only_share_contract_failed')
+ receipt.update(share_owner='Backup', share_permissions=4)
+ token = share['token']
+ # Existing backup clients already implement this file-drop endpoint and auth.
+ upload_root = HOST + '/public.php/dav/filesdrop/' + token
+ filename = 'account-acceptance-' + secrets.token_hex(12) + '.age'
+ receipt['fixture_name'] = filename
+ fixture_path = '/forgejo/' + filename
+ plain = b'railiance-backup-account-acceptance-v1\n' + secrets.token_bytes(64)
+ encrypted = subprocess_bytes(['age', '-r', recipient], plain)
+ require(len(encrypted) < receipt['quota_before']['available_bytes'], 'insufficient_quota')
+ code, _ = request(upload_root + fixture_path, 'PUT', encrypted, (token, ''), {'Content-Type':'application/octet-stream','If-None-Match':'*'})
+ receipt['upload_http_status'] = code
+ require(code in (200, 201, 204), 'upload_fixture_failed')
+ code, downloaded = request(owner_root + fixture_path, auth=auth)
+ require(code == 200 and downloaded == encrypted, 'owner_download_mismatch')
+ with tempfile.TemporaryDirectory(prefix='backup-account-acceptance-') as tmp:
+ cipher = Path(tmp) / 'fixture.age'
+ cipher.write_bytes(downloaded)
+ restored = subprocess_bytes(['age', '-d', '-i', '/dev/stdin', str(cipher)], (values['AGE_PRIVATE_KEY'].strip() + '\n').encode())
+ require(restored == plain, 'decrypted_fixture_mismatch')
+ for method in ['GET', 'DELETE']:
+ code, _ = request(HOST + '/public.php/webdav' + fixture_path, method, auth=(token, ''))
+ require(code in (401, 403, 404), 'upload_grant_exceeds_create_only')
+ code, present = request(owner_root + fixture_path, auth=auth)
+ require(code == 200 and present == encrypted, 'fixture_not_preserved_by_negative_check')
+ receipt.update(encrypted_upload=True, owner_download=True, escrow_decryption=True,
+ runtime_read_denied=True, runtime_delete_denied=True)
+ # Only replace upload coordinates; preserve recovery escrow and other fields.
+ updated = dict(values, NC_WEBDAV_TOKEN=token, NC_WEBDAV_URL=upload_root)
+ result = data(bao(['write', '-format=json', LANE, '-'], payload={'options':{'cas':expected_version}, 'data':updated}))
+ receipt['kv_version'] = result['data']['version']
+ receipt['escrow_preserved'] = True
+ code, _ = request(owner_root + fixture_path, 'DELETE', auth=auth)
+ require(code == 204, 'fixture_cleanup_failed')
+ receipt['fixture_removed'] = True
+ receipt['quota_after'] = quota(auth)
+ receipt['status'] = 'account_cutover_verified_pending_consumer_refresh'
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--expected-version', type=int)
+ p.add_argument('--receipt', required=True)
+ p.add_argument('--confirm', required=True)
+ args = p.parse_args()
+ receipt = {'schema':'platform.nextcloud-backup-account-cutover.v1', 'status':'failed'}
+ fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ try:
+ require(args.confirm == 'MIGRATE TO Backup', 'confirmation_mismatch')
+ run(args, receipt)
+ except Exception as error:
+ receipt['error'] = str(error) if isinstance(error, LaneError) else 'internal_error'
+ finally:
+ with os.fdopen(fd, 'w') as out:
+ json.dump(receipt, out, indent=2)
+ out.write('\n')
+ return 0 if receipt['status'] != 'failed' else 1
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/tests/test_nextcloud_backup_account.py b/tests/test_nextcloud_backup_account.py
new file mode 100644
index 0000000..26ebc51
--- /dev/null
+++ b/tests/test_nextcloud_backup_account.py
@@ -0,0 +1,40 @@
+import json
+from pathlib import Path
+import sys
+from types import SimpleNamespace
+import unittest
+from unittest.mock import patch
+import urllib.error
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
+import migrate_nextcloud_backup_account as migration
+
+
+class BackupAccountTests(unittest.TestCase):
+ def test_requests_refuse_other_provider_origins(self):
+ with patch.object(migration.urllib.request, 'build_opener') as opener:
+ with self.assertRaises(migration.LaneError):
+ migration.request('https://different.invalid/remote.php/dav', auth=('Backup','fixture'))
+ opener.assert_not_called()
+
+ def test_http_failure_exposes_only_status_not_credential_body(self):
+ with patch.object(migration.urllib.request, 'build_opener') as opener:
+ opener.return_value.open.side_effect = urllib.error.HTTPError('https://fixture.invalid/fixture-secret',403,'fixture-secret',{},None)
+ self.assertEqual(migration.request(migration.HOST+'/probe'),(403,b''))
+
+ def test_wrong_quota_rejects_before_cutover(self):
+ body=b'502'
+ with patch.object(migration,'request',return_value=(207,body)):
+ with self.assertRaisesRegex(migration.LaneError,'quota_contract_mismatch'):
+ migration.quota(('Backup','fixture'))
+
+ def test_expected_version_mismatch_stops_before_provider_mutation(self):
+ responses=[{'data':{'policies':['platform-admin']}}, {'data':{'data':{'BACKUP_USERNAME':'Backup','BACKUP_PASSWORD':'fixture'}}}, {'data':{'metadata':{'version':2},'data':{}}}]
+ with patch.object(migration,'bao',side_effect=[SimpleNamespace(stdout=json.dumps(x).encode()) for x in responses]), patch.object(migration,'quota',return_value={}), patch.object(migration,'request') as provider:
+ with self.assertRaisesRegex(migration.LaneError,'kv_version_changed'):
+ migration.run(SimpleNamespace(expected_version=99),{})
+ provider.assert_not_called()
+
+
+if __name__=='__main__':
+ unittest.main()
diff --git a/workplans/RPF-WP-0029-backup-credential-default-removal.md b/workplans/RPF-WP-0029-backup-credential-default-removal.md
index e546dbf..5b57a2a 100644
--- a/workplans/RPF-WP-0029-backup-credential-default-removal.md
+++ b/workplans/RPF-WP-0029-backup-credential-default-removal.md
@@ -4,7 +4,7 @@ type: workplan
title: "Remove backup credential default and verify governed replacement"
domain: financials
repo: railiance-platform
-status: blocked
+status: active
owner: codex
created: "2026-09-05"
updated: "2026-09-05"
@@ -59,3 +59,21 @@ S1 backup scheduling belongs to RAIL-HO-WP-0012; forge backup orchestration and
artifact retention belong to railiance-forge. RPF-WP-0036-T06 will obtain an
accepted compatibility handoff, but this exposure obligation stays visible
here until its evidence is accepted. No rotation was executed in this review.
+
+## Move future backups to the dedicated Backup account
+
+```task
+id: RPF-WP-0029-T03
+status: progress
+priority: high
+```
+
+User explicitly selected Nextcloud user `Backup` with 10 GB quota and operator
+credentials at UI `secrets/operators/nextcloud/backup`. Live resolution is KVv2
+`operators/nextcloud/backup`, fields BACKUP_USERNAME/BACKUP_PASSWORD. Native
+WebDAV login verified; actual quota is 10737418240 bytes (10 GiB). Keep the
+account password in operator custody, create a Backup-owned create-only share
+for workload delivery, and preserve the existing age escrow and retained data.
+Prove encrypted upload/download/decryption and workload delivery. No automatic
+pruning or personal-account revocation is inferred from this account change.
+The historical predecessor invalidation obligation in T02 remains separate.