Implement identity trust module

This commit is contained in:
2026-08-01 20:57:27 +02:00
parent 765fcd5a2d
commit 0ffc8b6b0f
20 changed files with 2058 additions and 5 deletions
View File
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.identity_trust import (
AssuranceCheckRequest,
DeviceKeyRegistration,
KeyAccessRequest,
KeyEpochRotationRequest,
)
from govoplan_identity_trust.backend.db.models import (
AssuranceEvidence,
DevicePublicKey,
KeyAccessDecisionRecord,
TrustKeyEpoch,
)
from govoplan_identity_trust.backend.service import (
IdentityTrustError,
SqlIdentityTrustService,
record_assurance_evidence,
)
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
class Principal:
tenant_id = "tenant-1"
account_id = "account-1"
def has(self, scope: str) -> bool:
return scope in {
"identity_trust:device:admin",
"identity_trust:device:read_all",
}
def registration(**changes) -> DeviceKeyRegistration:
values = {
"tenant_id": "tenant-1",
"identity_id": "identity-1",
"account_id": "account-1",
"device_id": "device-1",
"key_id": "key-1",
"algorithm": "X25519",
"public_jwk": {"kty": "OKP", "crv": "X25519", "x": "public"},
"purpose": "encryption",
"assurance_level": "hardware",
"idempotency_key": "register-1",
}
values.update(changes)
return DeviceKeyRegistration(**values)
class IdentityTrustTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
self.tables = [
DevicePublicKey.__table__,
TrustKeyEpoch.__table__,
AssuranceEvidence.__table__,
KeyAccessDecisionRecord.__table__,
]
for table in self.tables:
table.create(self.engine)
self.session = Session(self.engine)
self.service = SqlIdentityTrustService()
self.principal = Principal()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_public_key_registration_replay_revoke_and_private_rejection(self) -> None:
first = self.service.register_device_key(
self.session,
self.principal,
request=registration(),
)
replay = self.service.register_device_key(
self.session,
self.principal,
request=registration(),
)
self.assertEqual(first.key_id, replay.key_id)
with self.assertRaisesRegex(ValueError, "public JWK"):
registration(
key_id="private",
idempotency_key="private",
public_jwk={"kty": "OKP", "x": "public", "d": "private"},
)
revoked = self.service.revoke_device_key(
self.session,
self.principal,
tenant_id="tenant-1",
key_id="key-1",
expected_epoch=1,
reason="Device lost.",
)
self.assertEqual("revoked", revoked.status)
with self.assertRaisesRegex(IdentityTrustError, "changed"):
self.service.revoke_device_key(
self.session,
self.principal,
tenant_id="tenant-1",
key_id="key-1",
expected_epoch=1,
reason="Device lost.",
)
def test_epoch_history_and_key_access_decision(self) -> None:
self.service.register_device_key(
self.session,
self.principal,
request=registration(),
)
epoch = self.service.rotate_epoch(
self.session,
self.principal,
request=KeyEpochRotationRequest(
tenant_id="tenant-1",
subject_kind="postbox",
subject_id="postbox-1",
reason="Initial incumbent.",
access_decision_ref="access:grant-1",
idempotency_key="epoch-1",
previous_epoch=None,
history_policy="all_retained",
),
)
decision = self.service.decide_key_access(
self.session,
self.principal,
request=KeyAccessRequest(
tenant_id="tenant-1",
account_id="account-1",
device_key_id="key-1",
subject_kind="postbox",
subject_id="postbox-1",
key_epoch=epoch.epoch,
access_decision_ref="access:grant-1",
purpose="postbox.message.read",
requested_at=NOW,
resource_ref="postbox-message:1",
),
)
self.assertTrue(decision.allowed)
self.assertFalse(decision.provenance["cryptographic_material_released"])
next_epoch = self.service.rotate_epoch(
self.session,
self.principal,
request=KeyEpochRotationRequest(
tenant_id="tenant-1",
subject_kind="postbox",
subject_id="postbox-1",
reason="Incumbency changed.",
access_decision_ref="access:grant-2",
idempotency_key="epoch-2",
previous_epoch=1,
history_policy="all_retained",
),
)
self.assertEqual(2, next_epoch.epoch)
self.assertEqual(
"superseded",
self.service.resolve_epoch(
self.session,
tenant_id="tenant-1",
subject_kind="postbox",
subject_id="postbox-1",
epoch=1,
).state,
)
def test_assurance_must_be_recent_sufficient_and_device_bound(self) -> None:
record_assurance_evidence(
self.session,
self.principal,
tenant_id="tenant-1",
account_id="account-1",
evidence_ref="webauthn:assertion-1",
assurance_level="hardware",
provider_id="webauthn",
verified_at=NOW,
expires_at=NOW + timedelta(minutes=10),
device_key_id="key-1",
)
allowed = self.service.verify_assurance(
self.session,
self.principal,
request=AssuranceCheckRequest(
tenant_id="tenant-1",
account_id="account-1",
purpose="encryption.recovery.approve",
minimum_level="mfa",
evidence_ref="webauthn:assertion-1",
evaluated_at=NOW + timedelta(minutes=2),
maximum_age_seconds=300,
device_key_id="key-1",
),
)
stale = self.service.verify_assurance(
self.session,
self.principal,
request=AssuranceCheckRequest(
tenant_id="tenant-1",
account_id="account-1",
purpose="encryption.recovery.approve",
minimum_level="mfa",
evidence_ref="webauthn:assertion-1",
evaluated_at=NOW + timedelta(minutes=6),
maximum_age_seconds=300,
device_key_id="key-1",
),
)
self.assertTrue(allowed.allowed)
self.assertFalse(stale.allowed)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import unittest
from govoplan_core.core.identity_trust import (
CAPABILITY_IDENTITY_TRUST_ASSURANCE,
CAPABILITY_IDENTITY_TRUST_DIRECTORY,
)
from govoplan_identity_trust.backend.manifest import get_manifest
class IdentityTrustManifestTests(unittest.TestCase):
def test_manifest_exposes_headless_trust_capabilities(self) -> None:
manifest = get_manifest()
self.assertEqual("identity_trust", manifest.id)
self.assertEqual((), manifest.dependencies)
self.assertIn(
CAPABILITY_IDENTITY_TRUST_DIRECTORY,
manifest.capability_factories,
)
self.assertIn(
CAPABILITY_IDENTITY_TRUST_ASSURANCE,
manifest.capability_factories,
)
self.assertIsNone(manifest.frontend)
self.assertIsNotNone(manifest.migration_spec)
self.assertEqual("vertical_slice", manifest.architecture.maturity)
if __name__ == "__main__":
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_identity_trust.backend.manifest import get_manifest
class IdentityTrustMigrationTests(unittest.TestCase):
def test_migration_creates_all_trust_tables(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-identity-trust-"
) as directory:
url = f"sqlite:///{Path(directory) / 'trust.db'}"
migrate_database(
database_url=url,
enabled_modules=("identity_trust",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
tables = set(inspect(engine).get_table_names())
self.assertTrue(
{
"identity_trust_device_keys",
"identity_trust_key_epochs",
"identity_trust_assurance_evidence",
"identity_trust_key_access_decisions",
}.issubset(tables)
)
with engine.connect() as connection:
self.assertIn(
"c3f5a7b9d1e2",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()