diff --git a/scripts/migrate_nextcloud_backup_account.py b/scripts/migrate_nextcloud_backup_account.py
deleted file mode 100644
index 2b8735e..0000000
--- a/scripts/migrate_nextcloud_backup_account.py
+++ /dev/null
@@ -1,176 +0,0 @@
-#!/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
deleted file mode 100644
index 26ebc51..0000000
--- a/tests/test_nextcloud_backup_account.py
+++ /dev/null
@@ -1,40 +0,0 @@
-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 da1cf6a..e546dbf 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: active
+status: blocked
owner: codex
created: "2026-09-05"
updated: "2026-09-05"
@@ -59,22 +59,3 @@ 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
-state_hub_task_id: "f85b1b4e-9a20-56e2-9e7d-d1d6f1c77bd9"
-```
-
-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.