Skip to content

[OpenSpec] sharing-federated-recipients #789

Description

@github-actions

⚠️ OpenSpec-managed issue — this content is automatically synced
from the openspec/ directory. Manual edits will be overwritten on next sync.

Artifacts

Specs

Tasks

  • 1.1 Add the three federation tables, the federated_source and read_only columns on secrets, a migration step and a <version> bump. Verify: a PHPUnit migration test asserts the tables and columns. Done: keepiq_federation_partners (lane G4, Version001011), then keepiq_federated_shares and keepiq_federated_inbound in Version001000's SCHEMA and Version001012Date20261004120000, which also adds secrets.federated_source and secrets.read_only; version 0.3.4-unstable.20261004120000; entities FederatedShare, FederatedInbound and their mappers, both tables in BackupTableRegistry. FederatedSharesMigrationTest asserts every column of both tables and the two secrets columns, and that a second run changes nothing; ConsolidatedSchemaMigrationTest checks SCHEMA against the mapper table names. The outbound table also carries pending_notification, notify_attempts and next_notify_at for the retries of task 4.2, and the inbound table a name for the incoming list.
  • 1.2 Add a federation block (enabled flag, root certificate fingerprint) to the discovery document at GET /api/v1/app/.well-known/keepiq. Verify: PHPUnit asserts the fingerprint matches the instance root. Done: DiscoveryController::document() federation block from FederationRootService; FederationPartnerTest::testTheDiscoveryDocumentPublishesTheRootFingerprint and testRootFingerprintIsSha256OverTheDerLikeOpenssl (matches openssl_x509_fingerprint).
  • 1.3 Add the admin partner section and endpoints (#[AuthorizedAdminSetting] plus #[PasswordConfirmationRequired]): add by URL, show and confirm the fetched fingerprint, set outbound and inbound, remove; show "needs Nextcloud 33" below 33. Verify: PHPUnit for add, pin and the version gate; vitest for the section. PARTIAL (lane G4): backend done (FederationPartnerController index, preview, create, update, destroy at /api/v1/federation/partners; FederationPartnerService pins the confirmed fingerprint and refuses one that changed, https only, 409 below Nextcloud 33; FederationPartnerTest). Admin section built: src/components/settings/FederationPartnersSection.vue (own fingerprint, check partner, compare and confirm, directions, remove, password confirmation, Nextcloud 33 note), tests/components/FederationPartnersSection.spec.js. Left open only for the live check. Live check done on the two-instance pair (task 5.1, 4 Oct): each administrator added the other through the section's endpoints with password confirmation; the preview read the partner's fingerprint and setup.sh compared it with the partner's discovery document; a wrong pin answered 400, the confirmed pin 201, an unauthenticated call 401; removing the partner answered 200 and withdrew the keepiq OCM capability; re-adding restored it.
  • 2.1 Advertise the OCM capability keepiq through a LocalOCMDiscoveryEvent listener while a partner exists, and answer POST /ocm/keepiq/recipient-certificate in an OCMEndpointRequestEvent listener that takes the signer from getRemote(), refuses unsigned requests and signers that are not inbound partners, answers only users of this instance who allow receiving and hold an active suite, and otherwise answers as for an unknown user. Verify: PHPUnit with the real event classes for an unsigned request, a non-partner, a user who opted out, an unknown user and an allowed lookup. Done: FederationOcmDiscoveryListener, FederationOcmRequestListener, FederatedCertificateService::answer(), user preference federation_receive (default '0'); FederationOcmRequestListenerTest with the real OCMEndpointRequestEvent asserts every refusal is byte-identical to the unknown-user answer.
  • 2.2 Add the owner-facing lookup that parses the cloud id with ICloudIdManager, calls the partner's /ocm/keepiq/recipient-certificate through IOCMDiscoveryService::requestRemoteOcmEndpoint('keepiq', ...), and returns the certificate chain. Verify: PHPUnit with a mocked discovery service. Done: POST /api/v1/federation/recipient-certificate (FederationController, FederatedCertificateService::lookup()), returning the certificate, chain and pinned root fingerprint; FederationLookupTest asserts the exact OCM call and that nothing is sent to a non-partner or a partner without outbound.
  • 2.3 In the share dialog, add the federated recipient option, verify the chain against the pinned root and the common name in the browser, show the fingerprint, and encrypt. Verify: vitest refuses a chain that ends at another root and a mismatched common name. PARTIAL (lane G4): the verifier is built, src/crypto/federatedCertificate.js verifyFederatedCertificate() (pinned root by SHA-256, every signature PKCS#1 v1.5 or RSASSA-PSS SHA-256, issuer names, common name equals the cloud id, validity); tests/vitest/federatedCertificate.spec.js refuses another root, a same-named CA that did not sign, a wrong common name, an expired certificate and garbage, on openssl fixtures from tests/fixtures/generate-federation-chain.sh. The share dialog option that calls it is owed. Done: the dialog half. SecretShareDialog shows src/components/share/FederatedShareForm.vue only while GET /api/v1/federation/status says an outbound partner exists; the form asks the owner's server for the certificate, runs verifyFederatedCertificate() (pinned root, signatures, common name, validity), shows the fingerprint, and only then encrypts key, login and additional fields for the recipient and posts the ciphertext to POST /api/v1/secrets/{id}/federated-shares (with the vault key proof). A refused certificate says "The certificate could not be verified. Nothing was shared." and decrypts and sends nothing. tests/components/FederatedShareForm.spec.js on the real openssl fixtures: another root and a mismatched common name are refused with no secret read and no share posted, a verified certificate shows its fingerprint and only ciphertext leaves; the dialog offers nothing without an outbound partner. Red when the verifier call is bypassed (3 of 6). Also the personal setting "Receive secrets from other organisations" (D6, FederationReceiveSection.vue, tests/components/FederationReceiveSection.spec.js), which until now had no screen.
  • 3.1 Register the keepiq-secret OCM provider in Application.php and send outbound shares with sendCloudShare() after POST /api/v1/secrets/{id}/federated-shares stores the ciphertext. Verify: PHPUnit asserts the OCM share carries no ciphertext. Done: KeepiqSecretFederationProvider registered at boot for keepiq-secret (only where OCMEndpointRequestEvent exists, Nextcloud 33+) through FederationEventRegistrar::boot(); POST /api/v1/secrets/{secretId}/federated-shares (FederatedShareController, vault key proof as for any new recipient) stores the ciphertext in keepiq_federated_shares and FederatedShareMessenger announces it with sendCloudShare() (protocol keepiq, a 64-character shared secret, only its SHA-256 kept). A refused delivery keeps nothing. FederatedShareSendTest asserts the OCM share holds none of the three ciphertexts nor the owner's own, and refuses non-partners, a partner without outbound, a non-owner, a read-only copy, a duplicate and a malformed request.
  • 3.2 Implement shareReceived() (inbound partner check, pending row, notification) and the "Incoming from other organisations" list with accept and decline. Verify: PHPUnit for a non-partner sender and a pending row; vitest for the list. Done: FederatedShareReceiver (from shareReceived()) stores a pending keepiq_federated_inbound row only when IOCMDiscoveryService::getIncomingSignedRequest() returns a signer that is an inbound partner, the owner's cloud id names that instance, and the user exists and opted in; every refusal is the same 403 Share refused and nothing is stored or notified; the user gets the federated_share_received notification. GET /api/v1/federation/incoming, POST .../{id}/accept, POST .../{id}/decline; page "Incoming from other organisations" (src/views/IncomingSharesView.vue, menu entry). FederatedShareReceiveTest (9 refusals: non-partner, partner without inbound, unsigned, badly signed, owner on another instance than the signer, not opted in, unknown user, other resource type, group share; duplicate; provider registration at boot), tests/views/IncomingSharesView.spec.js.
  • 3.3 On acceptance, pull the ciphertext from the sender's /ocm/keepiq/shares/{id} through requestRemoteOcmEndpoint() with the shared secret in the payload, and store a read-only Secret owned by the recipient. The sender's OCMEndpointRequestEvent listener answers only the recipient partner's signer with the matching shared secret. Verify: PHPUnit for the pull, the stored flags, and refusal of a wrong shared secret, another signer and an unsigned request. Done: accepting runs FederatedSharePuller::pull() (requestRemoteOcmEndpoint('keepiq', <partner>, 'keepiq/shares/{id}', {sharedSecret}, 'post')) and FederatedCopyService::pullNew() stores a Secret owned by the recipient with read_only and federated_source, encrypted to their active suite; a pull that fails or answers for another recipient keeps the share pending. The sender's FederationOcmRequestListener answers /shares/{id} through FederatedShareService::answerPull() only for the recipient partner's signer with the matching shared secret on an active share. FederatedShareAcceptTest: the exact OCM call, the stored flags, failed pulls, only the recipient may answer, and five refused pulls (wrong secret, another partner, a stranger, unsigned, suspended) that all get the unknown answer. The secret sidebar shows a read-only copy with its sender and offers no edit, move or share.
  • 3.4 Refuse every write to a read_only secret for its owner (update, sync, share onward, link share). Verify: PHPUnit for each refused route. Done: Secret::assertNotReadOnly() (a ForbiddenException, "A copy from another organisation is read-only") runs from assertEditableByHolder() (update), assertOnwardShareable() (direct, batch, group and delegation shares, link shares) and ShareSyncService::syncUpdate(); isRestrictedCopy() includes it, so the bulk register answers restricted and a team folder never fans it out. Each controller answers 403. ReadOnlyFederatedCopyTest calls every route through its controller with the real services: PUT /secrets/{id}, PUT /secrets/{id}/sync, POST shares, shares/batch, register-batch, group-shares, delegations and link-shares; red on development (each route wrote), green after.
  • 3.5 Let a recipient file a read-only copy in one of their folders: accept a change of folderId alone, keep refusing the value, login, fields, name, URL and type, and show Move again in the sidebar (decision of 4 Oct 2026). Verify: PHPUnit through the update route; vitest for the sidebar. Done: Secret::assertEditableByHolder(fields) with Secret::FILING_FIELDS, called from SecretService::update(); SecretDetailSidebar shows Move and Delete for a copy, and hides edit, archive and share. tests/Unit/Federation/ReadOnlyCopyFilingTest.php (PUT with only folderId stores it, back to the top level too; seven mixes with another field answer 403 and write nothing; red before: both filing cases answered 403), tests/components/SecretDetailSidebar.federatedCopy.spec.js (red before: no Move or Delete on a copy). ReadOnlyFederatedCopyTest still refuses every other route. Live on the kq-fed pair (4 Oct): B's sidebar offers Move on the copy, a PUT with only folderId stored the folder, a PUT that also sent a name was refused and the name stayed (that route's OCS layer still answers HTTP 200 with 403 in the envelope, the 428 change being another lane's), and a later pull kept the folder.
  • 4.1 Extend the browser's sync step to encrypt for federated recipients with a freshly verified certificate, and send SHARE_UPDATED; handle it in notificationReceived() with a new pull. Verify: vitest for the extra recipient; PHPUnit for the notification handler. Done: after the owner's update, useFederatedShareStore().syncUpdate() (called from useSecretStore().updateSecret) fetches each live recipient's certificate again, verifies it, encrypts the whole value and sends PUT /api/v1/federated-shares/{id}; FederatedShareService::update() stores it and FederatedNotificationDelivery sends SHARE_UPDATED; FederatedRemoteChangeService (from notificationReceived()) pulls again and replaces the copy. tests/store/federatedShare.sync.spec.js (real verifier and fixtures: the extra recipient gets ciphertext only; another root or an unknown recipient suspends; a suspended share is skipped), FederatedShareOwnerChangesTest, FederatedRemoteChangeTest.
  • 4.2 Revoke with SHARE_UNSHARED and delete the remote copy; suspend shares whose partner was removed or whose certificate no longer verifies; retry failed notifications from a background job with backoff. Verify: PHPUnit for revoke, suspend and retry. Done: DELETE /api/v1/federated-shares/{id} marks the share revoked (nothing served) and sends SHARE_UNSHARED, removing the row once it arrives; the receiver deletes the copy. POST .../suspend (certificate no longer verifies, from the browser sync) and removing a partner (FederationPartnerController::destroy) suspend shares. Failed notifications are retried by RetryFederatedNotificationsJob (every minute) with waits of 1, 2, 4, 8 and 16 minutes, then the share is marked failed for the owner, who sees each state and can revoke in the share dialog. FederatedShareOwnerChangesTest (revoke delivered and retried, the doubling waits, give-up, suspend, partner removal), FederatedRemoteChangeTest (update, revoke, pending share not pulled, five refused notifications change nothing).
  • 4.3 Audit every federated share event on both sides with identifiers only. Verify: PHPUnit asserts no audit metadata holds ciphertext or the shared secret. Done: FederatedShareAuditTrail records sent, updated, revoked, suspended and failed on the sending side and received, accepted, declined, copy updated and copy removed on the receiving side, each with the share id, the other side's cloud id and the partner id only (whitelisted in AuditEventTypes). Both test classes collect every dispatched audit event and assert none holds a ciphertext, the shared secret or its hash, and only whitelisted keys.
  • 4.4 When a recipient trashes or purges an accepted read-only copy, mark the inbound share declined and send OCM SHARE_DECLINED; the owner's share shows "declined" and gets no more retries or updates (decision of 4 Oct 2026). Verify: PHPUnit for both sides and the refusals; vitest for the owner's state. Done: FederatedCopyDeclineService (from SecretTrashService::trash() and purge()), FederatedDeclineReceiver (from KeepiqSecretFederationProvider::notificationReceived(), which also names the recipient in getFederationIdFromSharedSecret()), FederatedShare::STATUS_DECLINED, audit federated_share.recipient_declined, the owner's state "Declined: they removed their copy. Share again if they need it." tests/Unit/Federation/FederatedCopyDeclineTest.php (14 tests: trash and purge decline and send once with the secret, an ordinary secret and another user's share stay put, an update for a declined share repeats the decline without a pull, the owner's share turns declined and drops its pending retry, a revocation on its way stays one, six refused declines change nothing); red with the trash hook removed (2 failures) and with the signer check removed (3 failures). tests/components/FederatedShareForm.spec.js "tells the owner that the recipient removed their copy" (red before). Live on the kq-fed pair (4 Oct): B deleted the copy from the sidebar, B's row turned declined (audit federated_share.declined), A's share turned declined with no pending retry (audit federated_share.recipient_declined, system actor) and the dialog showed the declined text; A's two later renames sent nothing; purging a copy trashed while its share was still accepted declined it too; with A's row forced back to active, A's next rename reached the declined row on B and the decline came back at once. Red on B with the trash hook removed ("Expected: Declined, Received: In your vault, read-only").
  • 4.5 When the owner changes only the name or URL of a federated source, send SHARE_UPDATED to each live federated recipient, server side and without new ciphertext (decision of 4 Oct 2026). Verify: PHPUnit through the update route. Done: FederatedShareService::detailsChanged() from SecretService::update(). tests/Unit/Federation/FederatedSourceDetailsTest.php: a new name or URL sends one notification per live share (suspended and declined shares get none) and no ciphertext; the same name sent again, a value change (the browser's sync notifies) and a type change send nothing. Red before (no notification). Live on the kq-fed pair (4 Oct): A renamed only, one federated_share.updated per live recipient, and B's copy showed the new name within the poll while staying in B's folder.
  • 5.1 Add an integration test with two Nextcloud 33 containers: partners pinned on both sides, owner shares with a remote user, the recipient accepts and reads the value in the browser, the owner updates and revokes. Verify: the test passes in a dedicated CI job. BUILT, CI run owed (lane L3): tests/integration/federation/ (compose.yaml, peer-proxy.sh, setup.sh, federated-sharing.spec.ts, playwright.config.ts) and the dedicated job .github/workflows/federation.yml. Green locally on the kq-fed pair (two Nextcloud 35 containers, 52 s): share from the dialog with the key proof, accept and read in B's browser, update reaches B, revoke removes B's copy. Red when B does not pull on SHARE_UPDATED (expected the new password, received the old). It found five defects, fixed in the stack: RFC 9421 signatures need a sender address on Keepiq's OCM calls, on incoming shares and on notifications (ISignedCloudFederationProvider); cloud ids of http instances carry the scheme; sharing again after a suspended or failed share. Left open until the CI job has run green once. Extended (lane F, 4 Oct) with a second test, "the recipient files the copy, follows a new name, and declines by deleting it" (tasks 3.5, 4.4, 4.5): red on the L3 code (no Move on the copy), green after; both tests green together on the pair (4.2 min).

Design

See design.md for technical design details.


Synced from openspec/changes/sharing-federated-recipients by OpenSpec workflow
App: keepiq

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    openspecOpenSpec managed changeopenspec:tasksOpenSpec phase: Tasks

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions