Implement identity trust module
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user