diff --git a/libparsec/crates/protocol/schema/authenticated_cmds/shamir_create_shared_recovery_device.json5 b/libparsec/crates/protocol/schema/authenticated_cmds/shamir_create_shared_recovery_device.json5 index 5b36e8f2d59..ed896b16c4d 100644 --- a/libparsec/crates/protocol/schema/authenticated_cmds/shamir_create_shared_recovery_device.json5 +++ b/libparsec/crates/protocol/schema/authenticated_cmds/shamir_create_shared_recovery_device.json5 @@ -68,4 +68,4 @@ } ] } -] \ No newline at end of file +] diff --git a/libparsec/crates/protocol/tests/authenticated_cmds/v4/shamir_recovery_setup.rs b/libparsec/crates/protocol/tests/authenticated_cmds/v4/shamir_recovery_setup.rs new file mode 100644 index 00000000000..1c303ea99bb --- /dev/null +++ b/libparsec/crates/protocol/tests/authenticated_cmds/v4/shamir_recovery_setup.rs @@ -0,0 +1,12 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use libparsec_tests_lite::prelude::*; + +use super::authenticated_cmds; + +// TODO +pub fn rep_already_set() {} +pub fn rep_invalid_certification() {} +pub fn rep_invalid_data() {} +pub fn rep_ok() {} +pub fn req() {} diff --git a/server/parsec/_parsec_pyi/certif.pyi b/server/parsec/_parsec_pyi/certif.pyi index d91945059cc..04debdd5465 100644 --- a/server/parsec/_parsec_pyi/certif.pyi +++ b/server/parsec/_parsec_pyi/certif.pyi @@ -393,17 +393,17 @@ class ShamirRecoveryShareCertificate: self, author: DeviceID | None, timestamp: DateTime, - realm_id: VlobID, - configuration: RealmArchivingConfiguration, + recipient: UserID, + ciphered_share: bytes, ) -> None: ... @property def author(self) -> DeviceID | None: ... @property def timestamp(self) -> DateTime: ... @property - def realm_id(self) -> VlobID: ... + def recipient(self) -> UserID: ... @property - def configuration(self) -> RealmArchivingConfiguration: ... + def ciphered_share(self) -> bytes: ... @classmethod def verify_and_load( cls, diff --git a/server/parsec/backend.py b/server/parsec/backend.py index 828c31f365d..7fda4d874e7 100644 --- a/server/parsec/backend.py +++ b/server/parsec/backend.py @@ -34,6 +34,7 @@ from parsec.components.postgresql import components_factory as postgresql_components_factory from parsec.components.realm import BaseRealmComponent from parsec.components.sequester import BaseSequesterComponent +from parsec.components.shamir import BaseShamirComponent from parsec.components.user import BaseUserComponent from parsec.components.vlob import BaseVlobComponent from parsec.config import BackendConfig @@ -68,6 +69,7 @@ async def backend_factory(config: BackendConfig) -> AsyncGenerator[Backend, None pki=components["pki"], sequester=components["sequester"], events=components["events"], + shamir=components["shamir"], ) @@ -88,6 +90,7 @@ class Backend: pki: BasePkiEnrollmentComponent sequester: BaseSequesterComponent events: BaseEventsComponent + shamir: BaseShamirComponent # Only available if `config.db_url == "MOCKED"` mocked_data: MemoryDatamodel | None = None @@ -106,6 +109,7 @@ def __post_init__(self) -> None: self.block, self.pki, self.events, + self.shamir, # Ping command is only used in tests include_ping=self.config.debug, ) diff --git a/server/parsec/components/memory/datamodel.py b/server/parsec/components/memory/datamodel.py index b0f639d2780..853dde68a16 100644 --- a/server/parsec/components/memory/datamodel.py +++ b/server/parsec/components/memory/datamodel.py @@ -27,6 +27,7 @@ SequesterRevokedServiceCertificate, SequesterServiceCertificate, SequesterServiceID, + ShamirRecoveryBriefCertificate, UserCertificate, UserID, UserProfile, @@ -68,6 +69,8 @@ class MemoryOrganization: vlobs: dict[VlobID, list[MemoryVlobAtom]] = field(default_factory=dict) blocks: dict[BlockID, MemoryBlock] = field(default_factory=dict) block_store: dict[BlockID, bytes] = field(default_factory=dict, repr=False) + # The user id is the author of the shamir recovery process + shamir_setup: dict[UserID, MemoryShamirSetup] = field(default_factory=dict) @property def last_sequester_certificate_timestamp(self) -> DateTime: @@ -395,3 +398,24 @@ class MemoryBlock: created_on: DateTime # None if not deleted deleted_on: DateTime | None = None + + +@dataclass(slots=True) +class MemoryShamirSetup: + # The actual data we want to recover. + # It is encrypted with `data_key` that is itself split into shares. + # This should contains a serialized `LocalDevice` + ciphered_data: bytes + # The token the claimer should provide to get access to `ciphered_data`. + # This token is split into shares, hence it acts as a proof the claimer + # asking for the `ciphered_data` had it identity confirmed by the recipients. + reveal_token: bytes + # The Shamir recovery setup provided as a `ShamirRecoveryBriefCertificate`. + # It contains the threshold for the quorum and the shares recipients. + # This field has a certain level of duplication with the "shares" below, + # but they are used for different things (we provide the encrypted share + # data only when needed) + brief: ShamirRecoveryBriefCertificate + # The shares provided as a `ShamirRecoveryShareCertificate` since + # each share is aimed at a specific recipient. + shares: dict[UserID, bytes] diff --git a/server/parsec/components/memory/factory.py b/server/parsec/components/memory/factory.py index cbe48d7cd19..6edd3c8f94a 100644 --- a/server/parsec/components/memory/factory.py +++ b/server/parsec/components/memory/factory.py @@ -45,7 +45,7 @@ async def components_factory(config: BackendConfig) -> AsyncGenerator[dict[str, ping = MemoryPingComponent(event_bus) pki = MemoryPkiEnrollmentComponent(data, event_bus) sequester = MemorySequesterComponent(data, event_bus) - shamir = MemoryShamirComponent() + shamir = MemoryShamirComponent(data, event_bus) blockstore = blockstore_factory(config.blockstore_config, mocked_data=data) block = MemoryBlockComponent(data, blockstore) events = MemoryEventsComponent(data, config, event_bus) @@ -66,6 +66,7 @@ async def components_factory(config: BackendConfig) -> AsyncGenerator[dict[str, "sequester": sequester, "block": block, "blockstore": blockstore, + "shamir": shamir, } yield components diff --git a/server/parsec/components/memory/shamir.py b/server/parsec/components/memory/shamir.py index f82e0df5519..48de394012a 100644 --- a/server/parsec/components/memory/shamir.py +++ b/server/parsec/components/memory/shamir.py @@ -1,4 +1,40 @@ -from parsec.components.shamir import BaseShamirComponent +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +from parsec._parsec import DeviceID, OrganizationID, UserID, VerifyKey, authenticated_cmds +from parsec.components.events import EventBus +from parsec.components.memory.datamodel import ( + MemoryDatamodel, + MemoryShamirSetup, +) +from parsec.components.shamir import BaseShamirComponent, verify_certificates + class MemoryShamirComponent(BaseShamirComponent): - pass \ No newline at end of file + def __init__(self, data: MemoryDatamodel, event_bus: EventBus) -> None: + super().__init__() + self._data = data + self._event_bus = event_bus + + async def remove_recovery_setup( + self, + organization_id: OrganizationID, + author: UserID, + ) -> None: + self._data.organizations[organization_id].shamir_setup.pop(author) + + async def add_recovery_setup( + self, + organization_id: OrganizationID, + author: UserID, + device: DeviceID, + author_verify_key: VerifyKey, + setup: authenticated_cmds.latest.shamir_recovery_setup.ShamirRecoverySetup, + ) -> None | authenticated_cmds.latest.shamir_recovery_setup.Rep: + match verify_certificates(setup, device, author_verify_key): + case (brief, shares): + self._data.organizations[organization_id].shamir_setup[author] = MemoryShamirSetup( + setup.ciphered_data, setup.reveal_token, brief, shares + ) + + case authenticated_cmds.latest.shamir_recovery_setup.RepInvalidData() as error: + return error diff --git a/server/parsec/components/postgresql/factory.py b/server/parsec/components/postgresql/factory.py index 0cd8408fd76..c10c2297802 100644 --- a/server/parsec/components/postgresql/factory.py +++ b/server/parsec/components/postgresql/factory.py @@ -17,6 +17,7 @@ from parsec.components.postgresql.organization import PGOrganizationComponent from parsec.components.postgresql.ping import PGPingComponent from parsec.components.postgresql.realm import PGRealmComponent +from parsec.components.postgresql.shamir import PGShamirComponent from parsec.components.postgresql.user import PGUserComponent from parsec.components.postgresql.vlob import PGVlobComponent from parsec.config import BackendConfig @@ -53,6 +54,7 @@ async def components_factory( config=config.blockstore_config, postgresql_pool=pool ) block = PGBlockComponent(pool=pool) + shamir = PGShamirComponent() pki = None sequester = None @@ -72,6 +74,7 @@ async def components_factory( "blockstore": blockstore, "pki": pki, "sequester": sequester, + "shamir": shamir, } for component in components.values(): method = getattr(component, "register_components", None) diff --git a/server/parsec/components/postgresql/shamir.py b/server/parsec/components/postgresql/shamir.py new file mode 100644 index 00000000000..f887746db2c --- /dev/null +++ b/server/parsec/components/postgresql/shamir.py @@ -0,0 +1,8 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +from parsec.components.shamir import BaseShamirComponent + + +class PGShamirComponent(BaseShamirComponent): + # TODO + pass diff --git a/server/parsec/components/shamir.py b/server/parsec/components/shamir.py index 7949dc810ab..7bc8e96076a 100644 --- a/server/parsec/components/shamir.py +++ b/server/parsec/components/shamir.py @@ -1,8 +1,102 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +from __future__ import annotations + +from parsec._parsec import ( + DeviceID, + OrganizationID, + ShamirRecoveryBriefCertificate, + ShamirRecoveryShareCertificate, + UserID, + VerifyKey, + authenticated_cmds, +) from parsec.api import api -from parsec._parsec import authenticated_cmds from parsec.client_context import AuthenticatedClientContext + class BaseShamirComponent: @api - async def create_shared_recovery_device(self, client_ctx: AuthenticatedClientContext, req: authenticated_cmds.latest.shamir_recovery_setup.Req) -> authenticated_cmds.latest.shamir_recovery_setup.Rep: - pass + async def create_shared_recovery_device( + self, + client_ctx: AuthenticatedClientContext, + req: authenticated_cmds.latest.shamir_recovery_setup.Req, + ) -> authenticated_cmds.latest.shamir_recovery_setup.Rep: + if req.setup is None: + await self.remove_recovery_setup(client_ctx.organization_id, client_ctx.user_id) + return authenticated_cmds.latest.shamir_recovery_setup.RepOk() + else: + match await self.add_recovery_setup( + client_ctx.organization_id, + client_ctx.user_id, + client_ctx.device_id, + client_ctx.device_verify_key, + req.setup, + ): + case None: + return authenticated_cmds.latest.shamir_recovery_setup.RepOk() + case authenticated_cmds.latest.shamir_recovery_setup.Rep() as error: + return error + + # async def test_dump_current_shamir( + # self, organization_id: OrganizationID + # ) -> dict[UserID, ShamirDump]: + # raise NotImplementedError + + async def remove_recovery_setup( + self, + organization_id: OrganizationID, + author: UserID, + ) -> None: + raise NotImplementedError + + async def add_recovery_setup( + self, + organization_id: OrganizationID, + author: UserID, + device: DeviceID, + author_verify_key: VerifyKey, + setup: authenticated_cmds.latest.shamir_recovery_setup.ShamirRecoverySetup, + ) -> None | authenticated_cmds.latest.shamir_recovery_setup.Rep: + raise NotImplementedError + + +def verify_certificates( + setup: authenticated_cmds.latest.shamir_recovery_setup.ShamirRecoverySetup, + author: DeviceID, + author_verify_key: VerifyKey, +) -> ( + tuple[ShamirRecoveryBriefCertificate, dict[UserID, bytes]] + | authenticated_cmds.latest.shamir_recovery_setup.RepInvalidData +): + share_certificates: dict[UserID, bytes] = {} + try: + brief_certificate = ShamirRecoveryBriefCertificate.verify_and_load( + setup.brief, author_verify_key, expected_author=author + ) + except ValueError: + return authenticated_cmds.latest.shamir_recovery_setup.RepInvalidData() + + for raw_share in setup.shares: + try: + share_certificate = ShamirRecoveryShareCertificate.verify_and_load( + raw_share, author_verify_key, expected_author=author, expected_recipient=None + ) + except ValueError: + return authenticated_cmds.latest.shamir_recovery_setup.RepInvalidData() + + # share recipient not in brief + if share_certificate.recipient not in brief_certificate.per_recipient_shares: + return authenticated_cmds.latest.shamir_recovery_setup.RepInvalidData() + # this recipient already has a share + if share_certificate.recipient in share_certificates: + return authenticated_cmds.latest.shamir_recovery_setup.RepInvalidData() + # user included themselves as a share recipient + if share_certificate.recipient == author.user_id: + return authenticated_cmds.latest.shamir_recovery_setup.RepInvalidData() + share_certificates[share_certificate.recipient] = raw_share + delta = set(brief_certificate.per_recipient_shares) - set(share_certificates) + # some recipient specified in brief has no share + if delta: + return authenticated_cmds.latest.shamir_recovery_setup.RepInvalidData() + return brief_certificate, share_certificates diff --git a/server/tests/api_v4/authenticated/__init__.py b/server/tests/api_v4/authenticated/__init__.py index 27a0d4e2fa2..817fcc3d034 100644 --- a/server/tests/api_v4/authenticated/__init__.py +++ b/server/tests/api_v4/authenticated/__init__.py @@ -33,3 +33,4 @@ from .test_vlob_read_batch import * # noqa from .test_vlob_read_versions import * # noqa from .test_vlob_update import * # noqa +from .test_shamir_recovery_setup import * # noqa diff --git a/server/tests/api_v4/authenticated/test_shamir_recovery_setup.py b/server/tests/api_v4/authenticated/test_shamir_recovery_setup.py new file mode 100644 index 00000000000..385bb099d46 --- /dev/null +++ b/server/tests/api_v4/authenticated/test_shamir_recovery_setup.py @@ -0,0 +1,64 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +from parsec._parsec import ( + DateTime, + ShamirRecoveryBriefCertificate, + ShamirRecoveryShareCertificate, + authenticated_cmds, +) +from tests.common import Backend, CoolorgRpcClients, TestbedBackend + + +async def test_authenticated_shamir_recovery_setup_ok( + coolorg: CoolorgRpcClients, + backend: Backend, + testbed: TestbedBackend, +) -> None: + share = ShamirRecoveryShareCertificate( + coolorg.alice.device_id, DateTime.now(), coolorg.mallory.user_id, b"abc" + ) + brief = ShamirRecoveryBriefCertificate( + author=coolorg.alice.device_id, + timestamp=DateTime.now(), + threshold=1, + per_recipient_shares={coolorg.mallory.user_id: 2}, + ) + + setup = authenticated_cmds.v4.shamir_recovery_setup.ShamirRecoverySetup( + b"abc", + b"def", + brief.dump_and_sign(coolorg.alice.signing_key), + [share.dump_and_sign(coolorg.alice.signing_key)], + ) + rep = await coolorg.alice.shamir_recovery_setup(setup) + assert rep == authenticated_cmds.v4.shamir_recovery_setup.RepOk() + + # TODO dump + + +async def test_authenticated_shamir_recovery_setup_already_set( + coolorg: CoolorgRpcClients, + backend: Backend, + testbed: TestbedBackend, +) -> None: + pass + + +async def test_authenticated_shamir_recovery_setup_invalid_certification( + coolorg: CoolorgRpcClients, + backend: Backend, + testbed: TestbedBackend, +) -> None: + pass + + +async def test_authenticated_shamir_recovery_setup_invalid_data( + coolorg: CoolorgRpcClients, + backend: Backend, + testbed: TestbedBackend, +) -> None: + setup = authenticated_cmds.v4.shamir_recovery_setup.ShamirRecoverySetup( + bytes("abc", "utf-8"), bytes("def", "utf-8"), bytes("ijk", "utf-8"), [bytes("lmn", "utf-8")] + ) + rep = await coolorg.alice.shamir_recovery_setup(setup) + assert rep == authenticated_cmds.v4.shamir_recovery_setup.RepInvalidData()