Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1123,3 +1123,8 @@ scripts/verify-betacrew-restore.sh /var/lib/makepad/postgres-backups/betacrew/<t


BetaCrew backup uses uid 999 and a dedicated mode-0400 credential file owned by that uid. The shared host Compose service requires the `betacrew` profile; the separate `compose.betacrew.host.yml` remains available for a scoped backup-only deployment.

## Visitaki photo restore verification

See [scoped photo-memory restore evidence](docs/visitaki-photo-restore.md) before
using a backup receipt to authorize Visitaki photo uploads.
34 changes: 34 additions & 0 deletions docs/visitaki-photo-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Visitaki photo-memory restore evidence

Run only as the authorized database-host operator on `db-server-1`, after the
reviewed helper is installed through the normal server maintenance procedure.
This does not activate photos or alter the production database. Do not grant
the application workflow general database-root access.

1. Run the existing encrypted backup helper's `backup` operation and retain its
sanitized snapshot receipt. It reads only `visitaki` and `keycloak_visitaki`.
2. Run its `restore` operation using that exact snapshot and the checksum of
migration 026 from the deployed Visitaki release:

```sh
sudo /usr/bin/python3 /srv/makepad/visitaki-backups/visitaki-encrypted-backup.py restore --snapshot VERIFIED_SNAPSHOT_ID --photo-migration-sha256 VERIFIED_MIGRATION_SHA256
```

Current migration 026 source checksum (reverify against the deployed release):
`dc2c4e2c9f5ea9efeaed570415bd93da10c9eca1f4c5a60b3f97ca41b37868fc`.

The photo-specific mode requires a backup under 24 hours old, exact dump hashes,
the exact restored migration checksum, photo size/JPEG encoding bounds,
completion ownership, operation revision integrity and the enabled account
erasure trigger. Only a boolean leaves the restored database. It starts no app
services, exposes no ports and uses a network-disabled disposable PostgreSQL.
Success is emitted only after container and temporary plaintext cleanup.

Provide the sanitized JSON receipt, not dumps, photographs, identifiers or
credentials. An old receipt must not be reused after a failed attempt. The
consumer must check its timestamp, expected snapshot and migration checksum.

Separately report actual Restic access restrictions, retention policy and
deletion expiry, backup schedule health and database storage headroom. Successful
restore alone does not establish these policies or application/proxy logging.
Do not run Restic forget/prune or change retention as part of this verification.
50 changes: 50 additions & 0 deletions scripts/test-visitaki-backup-manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,63 @@
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import json
from types import SimpleNamespace

spec = importlib.util.spec_from_file_location('backup', Path(__file__).with_name('visitaki-encrypted-backup.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)


class IntegrityChecks(unittest.TestCase):
def test_restore_never_publishes_success_before_cleanup(self):
for cleanup_fails in [False, True]:
with self.subTest(cleanup_fails=cleanup_fails), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
dumps = {d: b'PGDMP' + d.encode() for d in module.DATABASES}
metadata = {'created_unix': 99999, 'sha256': {d: hashlib.sha256(v).hexdigest() for d, v in dumps.items()}}
def restic(*args, output=None):
if args[0] == 'snapshots':
return json.dumps([{'id': 'a'*64, 'tags': ['visitaki-preview'], 'paths': [str(root/'current')]}]).encode()
if args[-1].endswith('metadata.json'):
return json.dumps(metadata).encode()
output.write(dumps[Path(args[-1]).stem])
def run(args, **kwargs):
if args[:2] == ['docker', 'inspect']:
return SimpleNamespace(stdout=('sha256:'+'b'*64).encode())
if args[:2] == ['docker', 'rm']:
self.assertFalse((root/'restore-receipt.json').exists())
if cleanup_fails: raise RuntimeError('cleanup failed')
return SimpleNamespace(stdout=b't' if 'photo_memories' in args[-1] else b'10')
with patch.object(module, 'ROOT', root), patch.object(module, 'restic', side_effect=restic), \
patch.object(module, 'run', side_effect=run), patch.object(module.time, 'time', return_value=100000), \
patch.object(module.subprocess, 'run', return_value=SimpleNamespace(returncode=0)):
if cleanup_fails:
with self.assertRaises(RuntimeError): module.restore('a'*64, 'c'*64)
self.assertFalse((root/'restore-receipt.json').exists())
else:
module.restore('a'*64, 'c'*64)
result = json.loads((root/'restore-receipt.json').read_text())
self.assertTrue(result['cleanup_verified'])
self.assertTrue(result['photo_integrity_verified'])

def test_photo_restore_requires_current_backup_and_exact_digest(self):
with patch.object(module.time, 'time', return_value=100000):
module.validate_photo_backup({'created_unix': 99999}, 'a' * 64)
for created in [None, '99999', True, 100001, 1]:
with self.assertRaises(ValueError):
module.validate_photo_backup({'created_unix': created}, 'a' * 64)
with self.assertRaises(ValueError):
module.validate_photo_backup({'created_unix': 99999}, 'bad')

def test_photo_query_is_scoped_and_rejects_sql_input(self):
query = module.photo_integrity_query('a' * 64)
for expected in ['026_quest_photo_memories.sql', 'community_completions',
'quest_photo_memory_operations', '2097152', 'erase_disabled_account_memories']:
self.assertIn(expected, query)
with self.assertRaises(ValueError): module.photo_integrity_query("'; DROP TABLE accounts; --")

def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
Expand Down
52 changes: 47 additions & 5 deletions scripts/visitaki-encrypted-backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,34 @@ def backup():
current.rmdir()


def restore(snapshot):
def validate_photo_backup(metadata, expected_checksum):
if not re.fullmatch('[0-9a-f]{64}', expected_checksum):
raise ValueError('Exact photo migration checksum required')
created = metadata.get('created_unix')
if type(created) is not int or not 0 <= time.time() - created <= 86400:
raise ValueError('Photo restore requires a backup less than 24 hours old')


def photo_integrity_query(expected_checksum):
if not re.fullmatch('[0-9a-f]{64}', expected_checksum):
raise ValueError('Invalid migration checksum')
# Only a boolean leaves the restored database, never IDs or photographs.
return """SELECT EXISTS(SELECT 1 FROM schema_migrations
WHERE version='026_quest_photo_memories.sql' AND content_sha256='%s')
AND NOT EXISTS(SELECT 1 FROM quest_photo_memories m
LEFT JOIN community_completions c USING(account_id,quest_id)
WHERE c.account_id IS NULL OR m.revision < 1
OR (m.image IS NOT NULL AND (octet_length(m.image) < 3
OR octet_length(m.image) > 2097152
OR encode(substring(m.image from 1 for 3),'hex') <> 'ffd8ff')))
AND NOT EXISTS(SELECT 1 FROM quest_photo_memory_operations o
LEFT JOIN quest_photo_memories m USING(account_id,quest_id)
WHERE m.account_id IS NULL OR o.revision < 1 OR o.revision > m.revision)
AND EXISTS(SELECT 1 FROM pg_trigger WHERE tgname='erase_disabled_account_memories'
AND NOT tgisinternal AND tgenabled <> 'D');""" % expected_checksum


def restore(snapshot, photo_checksum=None):
assert re.fullmatch('[0-9a-f]{8,64}', snapshot)
records = json.loads(restic('snapshots', '--json', snapshot))
assert len(records) == 1 and 'visitaki-preview' in records[0].get('tags', [])
Expand All @@ -87,6 +114,8 @@ def restore(snapshot):
directory = Path(temporary)
prefix = str(ROOT / 'current') + '/'
metadata = json.loads(restic('dump', snapshot, prefix + 'metadata.json'))
if photo_checksum is not None:
validate_photo_backup(metadata, photo_checksum)
for database in DATABASES:
with (directory / (database + '.dump')).open('wb') as dump:
restic('dump', snapshot, prefix + database + '.dump', output=dump)
Expand All @@ -112,19 +141,31 @@ def restore(snapshot):
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public'"], stdout=subprocess.PIPE)
tables[database] = int(count.stdout)
assert tables['visitaki'] > 0
if photo_checksum is not None:
check = run(['docker', 'exec', container, 'psql', '-XAt', '-v', 'ON_ERROR_STOP=1',
'-U', 'postgres', '-d', 'visitaki', '-c', photo_integrity_query(photo_checksum)],
stdout=subprocess.PIPE)
if check.stdout.strip() != b't':
raise RuntimeError('Restored photo schema or data integrity failed')
receipt = {'snapshot_id': records[0]['id'], 'restored_databases': tables, 'image': image,
'network': 'none', 'checksums_verified': True, 'passed': True}
(ROOT / 'restore-receipt.json').write_text(json.dumps(receipt, indent=2))
print(json.dumps(receipt))
'network': 'none', 'checksums_verified': True,
'photo_integrity_verified': photo_checksum is not None,
'photo_migration_sha256': photo_checksum,
'backup_created_unix': metadata.get('created_unix')}
finally:
if created:
run(['docker', 'rm', '-fv', container], stdout=subprocess.DEVNULL)
# Both container removal and temporary plaintext cleanup must succeed first.
receipt.update(passed=True, cleanup_verified=True, verified_unix=int(time.time()))
(ROOT / 'restore-receipt.json').write_text(json.dumps(receipt, indent=2))
print(json.dumps(receipt))


def main():
parser = argparse.ArgumentParser()
parser.add_argument('operation', choices=['backup', 'restore'])
parser.add_argument('--snapshot')
parser.add_argument('--photo-migration-sha256')
args = parser.parse_args()
assert os.geteuid() == 0 and socket.gethostname() == 'db-server-1'
assert ENV.is_file() and not ENV.is_symlink() and ENV.stat().st_uid == 0
Expand All @@ -137,7 +178,8 @@ def main():
if args.operation == 'backup':
backup()
else:
restore(args.snapshot or json.loads((ROOT / 'latest.json').read_text())['snapshot_id'])
restore(args.snapshot or json.loads((ROOT / 'latest.json').read_text())['snapshot_id'],
args.photo_migration_sha256)


if __name__ == '__main__':
Expand Down