105 lines
6.3 KiB
Python
105 lines
6.3 KiB
Python
"""Retirement claims are authenticated, bounded, current-scope capabilities."""
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
|
|
class FileRetirementProof(unittest.TestCase):
|
|
def setUp(self):
|
|
source = Path(__file__).resolve().parents[1] / 'crates/supervisor/src/runner_files.py'
|
|
self.module = {}
|
|
exec(compile(source.read_text(), str(source), 'exec'), self.module)
|
|
self.key = 'ab' * 32
|
|
self.current = {'computer_id': 'new', 'bot_id': 'a', 'generation': 2}
|
|
self.proof = {'version': 1, 'current': self.current,
|
|
'origin': {'computer_id': 'old', 'bot_id': 'a', 'generation': 1},
|
|
'home_key': 'home', 'operation_id': 'operation', 'proof_id': 'ack-id',
|
|
'proof_kind': 'provider_destroy_ack', 'origin_provider_ref': 'old',
|
|
'issued_at': 1000, 'expires_at': 1060}
|
|
|
|
def sign(self, proof):
|
|
raw = proof if isinstance(proof, str) else json.dumps(proof, separators=(',', ':'))
|
|
return {'payload': raw, 'signature': hmac.new(bytes.fromhex(self.key), raw.encode(), hashlib.sha256).hexdigest()}
|
|
|
|
def verify(self, envelope, **changes):
|
|
with patch.object(self.module['time'], 'time', return_value=1001):
|
|
return self.module['file_retirement_proof'](envelope, changes.get('identity', self.current),
|
|
changes.get('home', 'home'), changes.get('key', self.key))
|
|
|
|
def test_valid_proof_and_signature_scope(self):
|
|
signed = self.sign(self.proof)
|
|
self.assertEqual(self.verify(signed), self.proof)
|
|
for changes in [{'key': 'cd' * 32}, {'home': 'other'},
|
|
{'identity': {**self.current, 'bot_id': 'b'}},
|
|
{'identity': {**self.current, 'computer_id': 'replacement'}},
|
|
{'identity': {**self.current, 'generation': 3}}]:
|
|
with self.assertRaisesRegex(ValueError, '^INVALID_FILE_RETIREMENT_PROOF$'):
|
|
self.verify(signed, **changes)
|
|
with self.assertRaisesRegex(ValueError, '^INVALID_FILE_RETIREMENT_PROOF$'):
|
|
self.verify({**signed, 'payload': signed['payload'].replace('operation', 'tampered')})
|
|
|
|
def test_signed_invalid_claims_still_fail_closed(self):
|
|
for change in [{'version': True}, {'version': 2}, {'extra': True}, {'proof_kind': 'changed_namespace'},
|
|
{'origin': {**self.proof['origin'], 'bot_id': 'b'}},
|
|
{'origin': {**self.proof['origin'], 'generation': 2}},
|
|
{'origin': {**self.proof['origin'], 'generation': True}},
|
|
{'issued_at': True}, {'issued_at': 1007}, {'issued_at': -1},
|
|
{'expires_at': 1001}, {'expires_at': 1061}, {'expires_at': 1000},
|
|
{'operation_id': '../file'}, {'proof_id': ''}, {'origin_provider_ref': ''}]:
|
|
with self.subTest(change=change):
|
|
with self.assertRaisesRegex(ValueError, '^INVALID_FILE_RETIREMENT_PROOF$'):
|
|
self.verify(self.sign({**self.proof, **change}))
|
|
|
|
def test_duplicate_fields_and_oversized_or_untrusted_envelopes_fail(self):
|
|
raw = json.dumps(self.proof)
|
|
invalid = [self.sign(raw[:-1] + ',"version":1}'), self.sign(' ' * 4097),
|
|
self.sign('[]'), self.sign('{broken'), {'payload': raw},
|
|
{**self.sign(self.proof), 'key': self.key},
|
|
{**self.sign(self.proof), 'signature': '0' * 64}]
|
|
for envelope in invalid:
|
|
with self.assertRaisesRegex(ValueError, '^INVALID_FILE_RETIREMENT_PROOF$'):
|
|
self.verify(envelope)
|
|
|
|
def test_retirement_audit_failure_rolls_back_receipt_and_outbox(self):
|
|
# Real SQLite transaction, with the already-verified filesystem
|
|
# boundary replaced by a fixture to isolate late audit failure.
|
|
proof = self.verify(self.sign(self.proof))
|
|
origin = proof['origin']
|
|
encoded = self.module['file_identity'](origin)
|
|
key = hashlib.sha256((encoded + ':operation').encode()).hexdigest()
|
|
data = b'data'
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
observation = {'source': {'path': 'file', 'sha256': digest, 'size': 4},
|
|
'helper_quiescent': True, 'controller_quiescent': True, 'desired_state_matches': True}
|
|
self.module['file_readback'] = lambda *args, **kwargs: observation
|
|
self.module['file_snapshot_store'] = lambda content, sha: sha
|
|
request = {'action': 'file_status', 'identity': origin,
|
|
'input': json.dumps({'operation_id': 'operation', 'readback': True, 'resolve': True})}
|
|
with sqlite3.connect(':memory:') as db:
|
|
self.module['file_journal_schema'](db)
|
|
db.execute('INSERT INTO file_operations VALUES (?,?,?,?,?,?,?)',
|
|
(key, encoded, 'digest', 'unknown', None, json.dumps({'action': 'write', 'path': 'file'}), 0))
|
|
self.module['file_event'](db, encoded, 'operation', 'accepted')
|
|
db.commit()
|
|
db.execute("CREATE TRIGGER reject_audit BEFORE INSERT ON file_retirement_receipts BEGIN SELECT RAISE(ABORT,'audit unavailable'); END")
|
|
with patch.object(self.module['subprocess'], 'run', return_value=SimpleNamespace(returncode=0, stdout=data)):
|
|
with self.assertRaisesRegex(sqlite3.IntegrityError, 'audit unavailable'):
|
|
self.module['file_journal_handle'](db, request, '', lambda *_: False, retirement=proof)
|
|
self.assertEqual(db.execute('SELECT status,result FROM file_operations').fetchone(), ('unknown', None))
|
|
self.assertEqual(db.execute('SELECT count(*) FROM file_retirement_receipts').fetchone()[0], 0)
|
|
self.assertEqual(db.execute('SELECT count(*) FROM file_outbox').fetchone()[0], 1)
|
|
db.execute('DROP TRIGGER reject_audit')
|
|
result = self.module['file_journal_handle'](db, request, '', lambda *_: False, retirement=proof)
|
|
self.assertEqual(result['status'], 'succeeded')
|
|
self.assertEqual(db.execute('SELECT count(*) FROM file_retirement_receipts').fetchone()[0], 1)
|
|
self.assertEqual(db.execute('SELECT count(*) FROM file_outbox').fetchone()[0], 2)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|