From d6aeba795b4f10d28732b0f9805fd074a299fbd2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:10:25 +0200 Subject: [PATCH 1/3] docs(openspec): specify SIEM connectors, extension release, extension unlock and accounts, SSH agent, offline edits --- .../.openspec.yaml | 2 + .../audit-siem-vendor-connectors/design.md | 93 ++++++++++++++ .../audit-siem-vendor-connectors/proposal.md | 54 ++++++++ .../specs/siem-vendor-connectors/spec.md | 116 ++++++++++++++++++ .../audit-siem-vendor-connectors/tasks.md | 43 +++++++ .../.openspec.yaml | 2 + .../clients-extension-store-release/design.md | 87 +++++++++++++ .../proposal.md | 69 +++++++++++ .../specs/extension-store-release/spec.md | 60 +++++++++ .../specs/extension-totp-autofill/spec.md | 31 +++++ .../clients-extension-store-release/tasks.md | 38 ++++++ .../.openspec.yaml | 2 + .../design.md | 90 ++++++++++++++ .../proposal.md | 73 +++++++++++ .../specs/browser-extension-autofill/spec.md | 18 +++ .../specs/extension-account-switching/spec.md | 41 +++++++ .../specs/extension-biometric-unlock/spec.md | 50 ++++++++ .../tasks.md | 29 +++++ .../clients-offline-edits/.openspec.yaml | 2 + .../changes/clients-offline-edits/design.md | 93 ++++++++++++++ .../changes/clients-offline-edits/proposal.md | 52 ++++++++ .../specs/offline-edit-queue/spec.md | 86 +++++++++++++ .../specs/offline-readonly-cache/spec.md | 7 ++ .../changes/clients-offline-edits/tasks.md | 38 ++++++ .../changes/clients-ssh-agent/.openspec.yaml | 2 + openspec/changes/clients-ssh-agent/design.md | 87 +++++++++++++ .../changes/clients-ssh-agent/proposal.md | 55 +++++++++ .../specs/cli-ssh-agent/spec.md | 93 ++++++++++++++ openspec/changes/clients-ssh-agent/tasks.md | 31 +++++ 29 files changed, 1444 insertions(+) create mode 100644 openspec/changes/audit-siem-vendor-connectors/.openspec.yaml create mode 100644 openspec/changes/audit-siem-vendor-connectors/design.md create mode 100644 openspec/changes/audit-siem-vendor-connectors/proposal.md create mode 100644 openspec/changes/audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md create mode 100644 openspec/changes/audit-siem-vendor-connectors/tasks.md create mode 100644 openspec/changes/clients-extension-store-release/.openspec.yaml create mode 100644 openspec/changes/clients-extension-store-release/design.md create mode 100644 openspec/changes/clients-extension-store-release/proposal.md create mode 100644 openspec/changes/clients-extension-store-release/specs/extension-store-release/spec.md create mode 100644 openspec/changes/clients-extension-store-release/specs/extension-totp-autofill/spec.md create mode 100644 openspec/changes/clients-extension-store-release/tasks.md create mode 100644 openspec/changes/clients-extension-unlock-lock-and-accounts/.openspec.yaml create mode 100644 openspec/changes/clients-extension-unlock-lock-and-accounts/design.md create mode 100644 openspec/changes/clients-extension-unlock-lock-and-accounts/proposal.md create mode 100644 openspec/changes/clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md create mode 100644 openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md create mode 100644 openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md create mode 100644 openspec/changes/clients-extension-unlock-lock-and-accounts/tasks.md create mode 100644 openspec/changes/clients-offline-edits/.openspec.yaml create mode 100644 openspec/changes/clients-offline-edits/design.md create mode 100644 openspec/changes/clients-offline-edits/proposal.md create mode 100644 openspec/changes/clients-offline-edits/specs/offline-edit-queue/spec.md create mode 100644 openspec/changes/clients-offline-edits/specs/offline-readonly-cache/spec.md create mode 100644 openspec/changes/clients-offline-edits/tasks.md create mode 100644 openspec/changes/clients-ssh-agent/.openspec.yaml create mode 100644 openspec/changes/clients-ssh-agent/design.md create mode 100644 openspec/changes/clients-ssh-agent/proposal.md create mode 100644 openspec/changes/clients-ssh-agent/specs/cli-ssh-agent/spec.md create mode 100644 openspec/changes/clients-ssh-agent/tasks.md diff --git a/openspec/changes/audit-siem-vendor-connectors/.openspec.yaml b/openspec/changes/audit-siem-vendor-connectors/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/audit-siem-vendor-connectors/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/audit-siem-vendor-connectors/design.md b/openspec/changes/audit-siem-vendor-connectors/design.md new file mode 100644 index 000000000..b79e4ca37 --- /dev/null +++ b/openspec/changes/audit-siem-vendor-connectors/design.md @@ -0,0 +1,93 @@ +# Design: SIEM vendor connectors + +## Context + +The SIEM export is built and specified in `openspec/specs/siem-audit-export/spec.md`. The code this change touches, at development `4c214a9d`: + +- `lib/Service/SiemTransport.php:81` `deliver()` is the single place the transport is chosen: `syslog` goes to `deliverSyslog()` (`:100`, RFC 5424 with RFC 6587 octet framing, PRI 134, the JSON payload as MSG) and everything else to `deliverWebhook()` (`:152`, HTTPS POST with an `X-Keepiq-Signature` HMAC header, the secret decrypted from `hmacSecretEnc` with `ICrypto` at `:156`). +- `lib/Service/SiemService.php:110` `buildPayload()` rebuilds each audit event through `AuditEventTypes::WHITELIST` and drops every `AuditEventTypes::FORBIDDEN_KEYS` entry (`lib/Event/Audit/AuditEventTypes.php:173`). The payload keys are `eventType`, `category`, `actorType`, `actorId`, `objectType`, `objectId`, `occurredAt` and `metadata`. +- `lib/Service/SiemService.php:237` `deliverOne()` drains one queued item per call; `lib/BackgroundJob/DeliverSiemEventsJob.php` runs the drain. +- `lib/Service/SiemSinkService.php:93` accepts only `syslog` or `webhook` as `type`, and `:102` requires `https://` for webhooks. +- `lib/Db/SiemSink.php:109` holds `hmacSecretEnc`; `jsonSerialize()` (`:265`) only reports `hasHmacSecret` (`:273`), never the value. +- `lib/Controller/SiemSinkController.php` gates every route in-body on `IGroupManager::isAdmin()` (the `adminUid()` helper near `:69`); routes are `appinfo/routes.php:203` to `:207`. +- `src/components/settings/SiemSection.vue:140` offers the type select with `['syslog', 'webhook']`. +- The table is `siem_sinks` in `lib/Migration/Version001000Date20260908000000.php:681` (`type` is `STRING(16)`). + +## Goals / Non-Goals + +**Goals:** + +- Three named connectors an administrator can pick: Splunk HEC, Microsoft Sentinel, and CEF over syslog. +- Every connector sends a mapping of the existing sanitized payload and nothing more. +- Connector credentials follow the webhook HMAC secret's rules: encrypted at rest, write-only, never logged. +- Receiving-side templates in the repository, so the Sentinel table and the Splunk sourcetype need no hand-built parser. + +**Non-Goals:** + +- Datadog, Elastic, Sumo Logic, CrowdStrike, Panther or Rapid7 presets. They accept the generic webhook or CEF today; a named preset for each is a later change if demand shows. +- Pulling events (a SIEM polling a Keepiq events API). This change stays push-only, like the existing export. +- Batching several events into one request. The queue drains one item per delivery, as today. +- Sentinel analytics rules, workbooks or Splunk dashboards. + +## Decisions + +### D1: Splunk and Sentinel are new transports; CEF is a format of syslog + +`splunk_hec` and `sentinel` speak their own wire protocols with their own authentication, so they are new values of `type` next to `syslog` and `webhook`. CEF is not a protocol; it is a message body that SIEMs expect on a syslog stream, so it is a new `format` column (`json` default, `cef`) that only a `syslog` sink may set. + +Alternative considered: one `preset` column that rewrites a webhook sink's URL and headers. Rejected: Sentinel needs an OAuth token exchange before each batch of posts, which a webhook preset cannot express, and a preset that silently changes transport behaviour is harder to test than an explicit transport. + +### D2: Splunk HTTP Event Collector + +The sink endpoint is the HEC URL (`https://:8088/services/collector/event`; `https://` required). Keepiq posts one event per request with the header `Authorization: Splunk ` and the body `{"time": , "host": "", "source": "keepiq", "sourcetype": "keepiq:audit", "index": "", "event": }`. Delivery succeeds on HTTP 200 with a response `code` of `0`; anything else is a transport failure that enters the existing retry and dead-letter path. `connectorOptions` holds the optional `index` and `sourcetype` override. + +Alternative considered: Splunk's raw endpoint (`/services/collector/raw`). Rejected: the event endpoint carries time and sourcetype explicitly, so no Splunk-side timestamp extraction is needed. + +### D3: Microsoft Sentinel through the Logs Ingestion API + +Keepiq uses the Azure Monitor Logs Ingestion API, not the HTTP Data Collector API that Microsoft is retiring. `connectorOptions` holds `tenantId`, `clientId`, the data collection endpoint URL, the data collection rule immutable id and the stream name (default `Custom-KeepiqAudit`), plus an `authorityHost` (default `https://login.microsoftonline.com`) for sovereign clouds. The client secret is the sink credential. + +Per drain run, the transport requests a token with the client-credentials grant and scope `https://monitor.azure.com//.default`, keeps it in the PHP process for that run only, and posts `[row]` to `/dataCollectionRules//streams/?api-version=2023-01-01` with `Authorization: Bearer `. HTTP 204 is success. A 401 clears the cached token and retries once in the same run. + +The row maps the payload one to one: `TimeGenerated` (from `occurredAt`), `EventType`, `Category`, `ActorType`, `ActorId`, `ObjectType`, `ObjectId` and `Metadata` (a dynamic column holding the whitelisted metadata object). + +Alternative considered: caching the token in Nextcloud's distributed cache across runs. Rejected: a bearer token is a credential, and a cache is not an encrypted store. One token request per drain run is cheap. + +### D4: CEF formatting + +A `cef` syslog sink sends `CEF:0|Conduction|Keepiq|||||` as the RFC 5424 MSG. Header fields escape `\` and `|`; extension values escape `\`, `=` and line breaks, as the CEF specification requires. Extensions: `rt` (event time in epoch milliseconds), `cat` (category), `act` (event type), `suser` (actor id when the actor is a user), `cs1Label=actorType cs1`, `cs2Label=objectType cs2`, `cs3Label=objectId cs3`, and `msg` (the whitelisted metadata as compact JSON). Severity comes from a fixed map keyed on category (for example `honey` 10, `suite` 8, `emergency` 7, `share` 5, everything else 3) kept next to the formatter and covered by a test. + +Alternative considered: LEEF for QRadar. Rejected for this change: QRadar parses CEF, so one format covers QRadar, ArcSight and Sentinel's CEF connector. LEEF can follow if a customer asks. + +### D5: Formatters are separate, pure classes + +Each output shape is a small pure class under `lib/Service/Siem/` (`JsonFormatter`, `CefFormatter`, `SplunkHecFormatter`, `SentinelRowFormatter`) that takes the `buildPayload()` array and returns a string or array. `SiemTransport::deliver()` picks the formatter and the transport. This keeps the no-secret-material rule testable in one place: a single test feeds every formatter a payload and asserts that no output value comes from anywhere but that payload and fixed vendor constants. + +### D6: Receiving-side templates live in `integrations/siem/` + +`integrations/siem/sentinel/keepiq-dcr.json` is an Azure Resource Manager template that creates the custom table `KeepiqAudit_CL` with the D3 columns and the data collection rule with stream `Custom-KeepiqAudit`. `integrations/siem/splunk/props.conf` defines the `keepiq:audit` sourcetype (`KV_MODE = json`, time taken from the HEC envelope). Both are copied into place by the administrator; neither is executed by Keepiq. A README in each directory lists the setup steps and the least privilege the credential needs (a HEC token scoped to one index; an Entra application with only the Monitoring Metrics Publisher role on the one data collection rule). + +## Security and zero-knowledge + +Nothing here touches vault content. The payload stays the sanitized audit entry: identifiers plus whitelisted metadata, never a secret value, login, additional field, ciphertext or key (ADR-003). The formatters only reshape it. + +Stored encrypted (with Nextcloud `ICrypto`, the server's own key): the Splunk HEC token and the Sentinel client secret, in the new `credential_enc` column. These are integration credentials that a background job must use unattended, so the server necessarily holds them in a form it can decrypt, exactly like `hmac_secret_enc` today. They are not vault secrets, they are never returned by any API (the sink reports only `hasCredential`), and they are decrypted in memory for one request. + +Stored in plain text: the connector type, the format, the endpoint URL, and `connector_options` (tenant id, client id, data collection endpoint, rule id, stream name, index, sourcetype). None of these is a credential. + +The sink routes stay admin-only through the existing in-body `isAdmin()` gate. Sink lifecycle audit events add the connector type as an identifier and never the credential. + +## Risks / Trade-offs + +- **An administrator points a connector at an internal URL.** The endpoint is admin-configured, as for the webhook today; Keepiq requires `https://` for HEC, Sentinel and webhook endpoints and uses Nextcloud's `IClientService`, which applies Nextcloud's local-address protection. +- **Sentinel column drift.** If Keepiq adds a payload key later, the data collection rule drops it until the template is updated. The template and the formatter carry the same column list, and a test compares them. +- **CEF severity is a judgement.** The map is small and documented; an administrator who disagrees can re-map in the SIEM. +- **One token request per drain run** adds a round trip to Entra ID. Acceptable at the drain cadence. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests use a mocked `IClientService` and a local socket listener; no development fixture is needed. + +## Migration + +A new migration step adds three columns to `keepiq_siem_sinks`: `format` (`STRING(16)`, not null, default `json`), `credential_enc` (`TEXT`, nullable) and `connector_options` (`TEXT`, nullable, JSON). Existing sinks keep `type` `syslog` or `webhook` and get `format` `json`, so their behaviour does not change. The `` in `appinfo/info.xml` must bump so Nextcloud runs the step. diff --git a/openspec/changes/audit-siem-vendor-connectors/proposal.md b/openspec/changes/audit-siem-vendor-connectors/proposal.md new file mode 100644 index 000000000..8aeed07fa --- /dev/null +++ b/openspec/changes/audit-siem-vendor-connectors/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Ready-made Splunk, Microsoft Sentinel and CEF connectors for the SIEM export + +## Why + +Keepiq already streams its sanitized audit events to a SIEM, but only as a generic syslog line or a generic signed webhook. An administrator who runs Splunk or Microsoft Sentinel has to build the receiving side by hand: a collector, a parser and a table. The three competitors that rate yes ship named connectors instead. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| audit-14 | Use ready-made connectors for Splunk, Microsoft Sentinel or similar tools. | No named connectors or vendor formats; Splunk, Sentinel and similar tools can ingest the generic syslog or webhook stream, but the admin has to configure the receiving side. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +This row is partial. What is built: the generic RFC 5424 syslog transport (`lib/Service/SiemTransport.php:100`) and the generic HMAC-signed HTTPS webhook (`lib/Service/SiemTransport.php:152`), with queueing, retry, dead-lettering and test-fire. The missing half, from the decision: named Splunk, Microsoft Sentinel and CEF presets on the SIEM export. + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts:182 Microsoft Sentinel, :188 Rapid7, :195 Elastic, :201 Panther, :207 Sumo Logic, :228 Splunk (HEC, flag EventManagementForSplunk), :263 CrowdStrike and Datadog (flag); bitwarden/server@v2026.9.1 src/Core/Dirt/Enums/IntegrationType.cs:9 Hec, :10 Datadog ... Integrations page with SIEM connectors" +- 1Password: "https://support.1password.com/events-reporting/ : Splunk, Microsoft Sentinel, Datadog, Elastic, CrowdStrike and more (Business)" +- Keeper: "https://docs.keeper.io/enterprise-guide/event-reporting : built-in SIEM connectors for Splunk, Microsoft Sentinel, QRadar, Elastic, Datadog, Sumo Logic and more" + +## What Changes + +- A SIEM sink gets a connector choice. Next to the existing `syslog` and `webhook` transports, an administrator can pick `splunk_hec` (Splunk HTTP Event Collector) or `sentinel` (Microsoft Sentinel through the Azure Monitor Logs Ingestion API). +- A syslog sink gets a `format` choice: `json` (today's behaviour) or `cef` (ArcSight Common Event Format). CEF covers QRadar, ArcSight and Sentinel's own CEF connector through the Azure Monitor Agent. +- Each connector maps the same sanitized payload that `SiemService::buildPayload()` already builds. No connector adds a field that the audit whitelist does not carry. +- Connector credentials (the Splunk HEC token and the Sentinel client secret) are encrypted at rest with Nextcloud's `ICrypto` and are write-only, exactly like the webhook HMAC secret today. +- The admin SIEM section shows a connector picker with only the fields that connector needs. +- Keepiq ships receiving-side templates under `integrations/siem/`: an Azure Resource Manager template for the Sentinel data collection rule and custom table, and a Splunk `props.conf` for the `keepiq:audit` sourcetype. + +## Capabilities + +### New Capabilities + +- `siem-vendor-connectors`: named Splunk HEC, Microsoft Sentinel and CEF connectors on a SIEM sink, their credential handling, payload mapping and receiving-side templates. + +### Modified Capabilities + +None. The generic syslog and webhook behaviour of `siem-audit-export` stays as specified; this change adds requirements in its own capability. + +## Impact + +- **Backend**: `SiemSink` gains `format`, `credentialEnc` and `connectorOptions`; `SiemSinkService` validates each connector; `SiemTransport` gains a Splunk HEC and a Sentinel delivery path and a CEF formatter for syslog; new formatter classes under `lib/Service/Siem/`. +- **Frontend**: `src/components/settings/SiemSection.vue` gets a connector picker and per-connector fields. +- **Database**: three new nullable or defaulted columns on `keepiq_siem_sinks`; a new migration step and a `` bump. +- **Security**: no secret material enters any payload; the new credentials are server-held integration credentials, not vault secrets, and follow the HMAC secret's write-only rule. +- **Cross-app**: none. OpenConnector is not involved. diff --git a/openspec/changes/audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md b/openspec/changes/audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md new file mode 100644 index 000000000..6d37c9918 --- /dev/null +++ b/openspec/changes/audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md @@ -0,0 +1,116 @@ +## ADDED Requirements + +### Requirement: Named SIEM connectors on a sink + +The system MUST let an administrator create a SIEM sink with one of these connectors: `splunk_hec` (Splunk HTTP Event Collector), `sentinel` (Microsoft Sentinel through the Azure Monitor Logs Ingestion API), or a `syslog` sink with `format` `cef`. Existing `syslog` and `webhook` sinks with `format` `json` MUST behave exactly as before. A `format` of `cef` MUST be refused on any sink that is not `syslog`. + +#### Scenario: Administrator creates a Splunk connector + +- **GIVEN** an administrator on the SIEM section of the Nextcloud admin settings +- **WHEN** they call `POST /api/v1/siem/sinks` with `type` `splunk_hec`, an `https://` HEC endpoint and a HEC token +- **THEN** the sink MUST be stored as enabled and eligible for delivery +- **AND** the response MUST report `hasCredential` true and MUST NOT contain the token + +#### Scenario: CEF on a webhook is refused + +- **GIVEN** an administrator creating a sink +- **WHEN** they call `POST /api/v1/siem/sinks` with `type` `webhook` and `format` `cef` +- **THEN** the system MUST reject the request with a bad-request response +- **AND** no sink MUST be stored + +#### Scenario: An existing syslog sink is unchanged + +- **GIVEN** a `syslog` sink created before this change +- **WHEN** the migration runs and the next audit event is delivered +- **THEN** the sink MUST report `format` `json` +- **AND** the delivered message MUST be the same JSON payload as before the change + +### Requirement: Splunk HTTP Event Collector delivery + +The system MUST deliver to a `splunk_hec` sink by posting one event per request to the configured endpoint with the header `Authorization: Splunk ` and a body carrying `time`, `host`, `source` `keepiq`, `sourcetype` (default `keepiq:audit`), an optional `index`, and the sanitized payload as `event`. Delivery MUST count as successful only on HTTP 200 with a response `code` of 0; any other outcome MUST enter the existing retry and dead-letter path. + +#### Scenario: Accepted event + +- **GIVEN** an enabled `splunk_hec` sink and a queued audit event +- **WHEN** the delivery job posts the event and Splunk answers HTTP 200 with `code` 0 +- **THEN** the queue item MUST be marked delivered and the sink's last delivery status MUST be `ok` + +#### Scenario: Rejected token + +- **GIVEN** an enabled `splunk_hec` sink whose token Splunk rejects +- **WHEN** the delivery job posts a queued event and Splunk answers HTTP 403 +- **THEN** the item MUST be scheduled for retry with backoff +- **AND** after the retry ceiling it MUST be dead-lettered and an administrator notification MUST be raised + +### Requirement: Microsoft Sentinel delivery through the Logs Ingestion API + +The system MUST deliver to a `sentinel` sink by obtaining an Entra ID token with the client-credentials grant for scope `https://monitor.azure.com//.default` and posting the payload as a row with the columns `TimeGenerated`, `EventType`, `Category`, `ActorType`, `ActorId`, `ObjectType`, `ObjectId` and `Metadata` to `/dataCollectionRules//streams/?api-version=2023-01-01`. HTTP 204 MUST count as success. The token MUST be held in process memory for one drain run only and MUST NOT be written to any cache or table. A 401 MUST clear the token and retry once in the same run. + +#### Scenario: One token serves a drain run + +- **GIVEN** an enabled `sentinel` sink with two queued events +- **WHEN** the delivery job drains the queue +- **THEN** the system MUST request exactly one token +- **AND** it MUST post two rows, each accepted with HTTP 204 + +#### Scenario: Expired token is refreshed once + +- **GIVEN** a drain run whose cached token has expired at Entra ID +- **WHEN** the stream post answers HTTP 401 +- **THEN** the system MUST request a new token and retry the post once +- **AND** a second 401 MUST enter the normal retry path + +### Requirement: CEF formatting over syslog + +The system MUST send, for a `syslog` sink with `format` `cef`, a message body of the form `CEF:0|Conduction|Keepiq|||||` inside the existing RFC 5424 frame. Header fields MUST escape `\` and `|`. Extension values MUST escape `\`, `=` and line breaks. Severity MUST come from a fixed map keyed on the event category. + +#### Scenario: CEF line reaches QRadar + +- **GIVEN** a `syslog` sink with `format` `cef` pointing at a QRadar syslog listener +- **WHEN** an administrator force-revokes a suite and the `suite.revoked` event is delivered +- **THEN** the listener MUST receive one octet-framed RFC 5424 message whose MSG starts with `CEF:0|Conduction|Keepiq|` +- **AND** the severity field MUST be the value the map assigns to the `suite` category + +#### Scenario: A pipe in a value cannot break the header + +- **GIVEN** an audit event whose whitelisted metadata contains the characters `|` and `=` +- **WHEN** the event is formatted as CEF +- **THEN** every `|` in a header field MUST be escaped as `\|` +- **AND** every `=` in an extension value MUST be escaped as `\=` + +### Requirement: Connector credentials are write-only and encrypted at rest + +The system MUST store the Splunk HEC token and the Sentinel client secret encrypted with Nextcloud `ICrypto` in `credential_enc`, MUST never return either in any API response, audit entry, log line or payload, and MUST keep the stored value when an update supplies a blank credential. Non-credential connector settings (tenant id, client id, data collection endpoint, rule id, stream, index, sourcetype) MAY be stored and returned in plain text. + +#### Scenario: Reading a sink never reveals its credential + +- **GIVEN** a `sentinel` sink with a stored client secret +- **WHEN** an administrator calls `GET /api/v1/siem/sinks` +- **THEN** the sink entry MUST include `hasCredential` true and the tenant and client ids +- **AND** it MUST NOT include the client secret or its ciphertext + +#### Scenario: Blank credential on update keeps the stored one + +- **GIVEN** a `splunk_hec` sink with a stored token +- **WHEN** an administrator calls `PUT /api/v1/siem/sinks/{id}` with a new index and an empty credential +- **THEN** the index MUST change and the stored token MUST remain in effect + +### Requirement: Connector output carries no secret material + +The system MUST build every connector's output only from the sanitized payload that `SiemService::buildPayload()` returns plus fixed vendor constants. No connector MUST add a secret value, login, password, additional field, ciphertext or key material, and a forbidden metadata key MUST never reach any connector's output. + +#### Scenario: A planted forbidden key is dropped for every connector + +- **GIVEN** an audit event whose metadata contains a `value` key +- **WHEN** the event is formatted for JSON, CEF, Splunk HEC and Sentinel +- **THEN** none of the four outputs MUST contain the `value` key or its content + +### Requirement: Receiving-side templates ship with the app + +The system MUST ship an Azure Resource Manager template under `integrations/siem/sentinel/` that creates the `KeepiqAudit_CL` table and the data collection rule with stream `Custom-KeepiqAudit`, and a `props.conf` under `integrations/siem/splunk/` that defines the `keepiq:audit` sourcetype. The Sentinel template's column list MUST match the columns the Sentinel formatter sends. + +#### Scenario: Template and formatter agree + +- **GIVEN** the Sentinel template in `integrations/siem/sentinel/keepiq-dcr.json` +- **WHEN** the test suite compares its table columns with the Sentinel formatter's output keys +- **THEN** the two lists MUST be identical diff --git a/openspec/changes/audit-siem-vendor-connectors/tasks.md b/openspec/changes/audit-siem-vendor-connectors/tasks.md new file mode 100644 index 000000000..bf9de196c --- /dev/null +++ b/openspec/changes/audit-siem-vendor-connectors/tasks.md @@ -0,0 +1,43 @@ +# Tasks: SIEM vendor connectors + +## 1. Data model + +- [ ] 1.1 Add a migration step that adds `format` (default `json`), `credential_enc` and `connector_options` to `keepiq_siem_sinks`, and bump `` in `appinfo/info.xml`. Verify: a PHPUnit migration test asserts the three columns and that an existing sink reads back `format` `json`. +- [ ] 1.2 Extend `SiemSink` with the three fields; `jsonSerialize()` returns `format`, `connectorOptions` and `hasCredential` but never the credential. Verify: PHPUnit `SiemSinkTest` asserts no serialized key holds the credential value. +- [ ] 1.3 Extend `SiemSinkService` validation: accept `splunk_hec` and `sentinel` as `type`, require `https://` endpoints for them, require the Sentinel options, allow `format` `cef` only on `syslog`, and encrypt a supplied credential with `ICrypto` (blank keeps the stored one). Verify: PHPUnit `SiemSinkServiceTest` covers each accept and reject path. + +## 2. Formatters + +- [ ] 2.1 Add `lib/Service/Siem/JsonFormatter` (today's output, unchanged) and `CefFormatter` with header and extension escaping and the category severity map. Verify: PHPUnit covers escaping of `|`, `\`, `=` and line breaks, and one line per category. +- [ ] 2.2 Add `SplunkHecFormatter` (event envelope with time, host, source, sourcetype, optional index) and `SentinelRowFormatter` (the D3 columns). Verify: PHPUnit snapshots of both outputs for a fixed payload. +- [ ] 2.3 Add a guard test that feeds every formatter a payload holding a planted forbidden key and asserts the output carries only payload-derived values and fixed vendor constants. Verify: the PHPUnit test fails when a formatter reads outside the payload. + +## 3. Transports + +- [ ] 3.1 Route `syslog` sinks with `format` `cef` through `CefFormatter` in `SiemTransport::deliverSyslog()`. Verify: PHPUnit with a local TCP listener reads back an octet-framed RFC 5424 line whose MSG starts with `CEF:0|Conduction|Keepiq|`. +- [ ] 3.2 Add Splunk HEC delivery: `Authorization: Splunk `, success only on HTTP 200 with response `code` 0. Verify: PHPUnit with a mocked `IClientService` covers success, a non-2xx, and a 200 with a non-zero `code` entering the retry path. +- [ ] 3.3 Add Sentinel delivery: client-credentials token request, per-run token reuse, post to the stream URL, 204 as success, one retry after a 401. Verify: PHPUnit asserts one token request for two deliveries in a run, and the retry-once behaviour. +- [ ] 3.4 Make test-fire work for every connector through the existing `SiemService::testSink()`. Verify: PHPUnit asserts the outcome message per connector. + +## 4. Admin interface + +- [ ] 4.1 Add a connector picker to `src/components/settings/SiemSection.vue` (Splunk HTTP Event Collector, Microsoft Sentinel, CEF over syslog, syslog JSON, webhook JSON) with only the fields each connector needs and a write-only credential field. Verify: vitest asserts the field set per connector and that the credential field is never pre-filled. +- [ ] 4.2 Add a Playwright flow: an administrator creates a Splunk HEC sink on the SIEM section of Nextcloud admin settings, runs test-fire against an unreachable endpoint and sees the failure outcome. Verify: the Playwright spec passes in the E2E job. + +## 5. Receiving side + +- [ ] 5.1 Add `integrations/siem/sentinel/keepiq-dcr.json` (custom table `KeepiqAudit_CL` and the data collection rule) with a README. Verify: a PHPUnit test compares the template's column list with `SentinelRowFormatter`; manual check with `az deployment group what-if` against a test workspace. +- [ ] 5.2 Add `integrations/siem/splunk/props.conf` for the `keepiq:audit` sourcetype with a README. Verify: manual check in a Splunk development container that a test-fire event lands with parsed fields. +- [ ] 5.3 Document each connector's setup and least-privilege credential in `docs/`. Verify: manual review against the writing rules. + +## 6. Audit + +- [ ] 6.1 Add the connector type to the sink create and update audit metadata (identifiers only). Verify: PHPUnit asserts the audit metadata holds the type and never the credential. + +## Acceptance criteria + +- An administrator can create a Splunk HEC, Microsoft Sentinel or CEF syslog sink from the SIEM section, and existing syslog and webhook sinks keep working unchanged. +- Every connector sends only values derived from `SiemService::buildPayload()` plus fixed vendor constants. +- The HEC token and the Sentinel client secret are encrypted at rest, never returned by the API, and never written to a log or audit entry. +- A failed connector delivery enters the existing retry, dead-letter and notification path. +- The Sentinel template and the Sentinel formatter carry the same columns. diff --git a/openspec/changes/clients-extension-store-release/.openspec.yaml b/openspec/changes/clients-extension-store-release/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/clients-extension-store-release/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/clients-extension-store-release/design.md b/openspec/changes/clients-extension-store-release/design.md new file mode 100644 index 000000000..5f39ff9af --- /dev/null +++ b/openspec/changes/clients-extension-store-release/design.md @@ -0,0 +1,87 @@ +# Design: extension store release and one-time code on the next step + +## Context + +Code at development `4c214a9d`: + +- `browser-extension/build.mjs:1` bundles five entries with esbuild into `browser-extension/dist/` and copies one `manifest.json`. It has no target option, no packing and no signing. +- `browser-extension/manifest.json` is a single MV3 manifest: `background.service_worker`, permissions `storage`, `activeTab`, `tabs`, `scripting`, `clipboardWrite`, `idle`, `windows`, optional `webAuthenticationProxy`, and host permissions for every `http` and `https` page. It has no `browser_specific_settings`. `scripting` has no call site in `browser-extension/src/` (`chrome.windows` is used by `browser-extension/src/passkey/orchestrator.js:45`). Its `description` contains an em-dash, which the store listing copy must not carry. +- `.github/workflows/` has `cli-release.yml` (Go CLI, `cli-v*` tags) and `release.yml` (the Nextcloud app); nothing builds or ships the extension. Extension tests live in `tests/extension/` and run under the root vitest. +- `lib/Controller/ExtensionController.php:123` returns `capabilities` from `pair()`, but no version. +- Code fill: `browser-extension/src/background/service-worker.js:113` `doFill()` fills the login, then `:137` computes the code for the tab host and `:141` sends one `fill-otp` message. `browser-extension/src/content/content-script.js:115` `fillOtp()` looks for a visible field matching `OTP_SELECTORS` (`:30`) once. If the field arrives on the next page, nothing fills it. The existing spec already allows filling "on the current or post-submit page (re-detecting after in-origin navigation)" (`openspec/specs/extension-totp-autofill/spec.md`, requirement "Optional OTP-field fill with fallback"), but the code never re-detects. +- The vault key lives only in the worker's memory (`browser-extension/src/lib/vault.js:27`); termination of the worker locks the vault. + +## Goals / Non-Goals + +**Goals:** + +- A user installs Keepiq from the Chrome Web Store, Firefox Add-ons or Edge Add-ons. +- An administrator can force-install it for a whole organisation. +- Every published package is built by CI from a tagged commit, from source a reviewer can rebuild. +- The code fills itself on the second login step, on the same site, shortly after the password fill. + +**Non-Goals:** + +- Safari. It needs an Xcode wrapper app and Apple distribution; a later change. +- Self-hosted Chrome update manifests. Chrome only installs off-store extensions through enterprise policy anyway. +- Matching over shared and team-folder secrets: already returned by the match endpoint (see the proposal). +- Filling a code on a different site than the login (for example a separate identity provider domain). The intent is bound to the login's site on purpose. + +## Decisions + +### D1: One source tree, a manifest per browser + +`build.mjs --target chrome` keeps today's manifest. `--target firefox` writes `browser_specific_settings.gecko.id` (`keepiq@conduction.nl`) and a minimum Firefox version, declares the worker bundle under `background.scripts` because Firefox runs MV3 backgrounds as event pages, and drops the Chrome-only `webAuthenticationProxy`. Edge uses the Chrome package. The manifest `version` comes from the tag. + +Alternative considered: a cross-browser polyfill and one universal manifest. Rejected: Chrome rejects the Firefox keys and Firefox rejects `service_worker`, so a per-target manifest is simpler than a runtime shim. + +### D2: A release workflow modelled on `cli-release.yml` + +`.github/workflows/extension-release.yml` runs on pull requests and pushes touching `browser-extension/**`, `src/crypto/**` or `src/totp/**` (the extension bundles those web-app modules verbatim). It runs the `tests/extension` vitest suite, builds both targets twice and compares the hashes (a reproducibility check), runs `web-ext lint` on the Firefox build, and uploads both zips as artefacts. + +On a tag `extension-v` a second job, bound to a protected GitHub environment `extension-stores` that needs a maintainer's approval, uploads and publishes to the Chrome Web Store API, signs and submits a listed version with `web-ext sign` to AMO (with the source archive and build steps AMO asks for bundled code), submits to the Edge Add-ons API, signs an unlisted Firefox package for self-hosting, and attaches everything to a GitHub release. + +Alternative considered: publishing by hand from a maintainer's machine. Rejected: nobody could then show which commit a store package came from. + +### D3: Store credentials only in the protected environment + +The Chrome Web Store client id, client secret and refresh token, the AMO API issuer and secret, and the Edge client credentials are GitHub environment secrets of `extension-stores`. No pull-request workflow can read them. Placeholders in documentation look like `YOUR_AMO_JWT_ISSUER`. + +### D4: Listings and permissions pass review + +Each store listing carries a privacy policy page (in `docs/`) that states the zero-knowledge model: the extension sends the server only ciphertext and index fields, and stores only the pairing (server URL, user, app password) in extension storage. Each permission gets a one-line justification. `scripting` is removed because nothing calls it. The listing copy is written with the writing skill, so the manifest `description` loses its em-dash. + +### D5: Version handshake + +`pair()` adds `serverVersion` to its response. The extension carries a minimum server version constant and shows "Update Keepiq on your server to use this extension version" instead of failing on an unknown route. Store auto-updates can then move ahead of an organisation's server without silent breakage. + +### D6: A one-shot code intent, bound to tab, site and time + +After a successful login fill with a matched `totp` secret, the worker writes `{ tabId, site, totpSecretId, expiresAt }` to `chrome.storage.session`, where `site` is the registrable domain from `browser-extension/src/lib/match.js` and `expiresAt` is five minutes out. `storage.session` is held in memory by the browser and not written to disk. The intent holds no seed and no code. + +The content script watches for a visible field matching `OTP_SELECTORS` (on load and through a throttled `MutationObserver`). When one appears it sends `otp-field-detected` with its own hostname. The worker fills only if all of these hold: an intent exists for `sender.tab.id`, the sender frame's registrable domain equals the intent's `site`, the intent has not expired, and the vault is unlocked. It then decrypts the seed, computes the current code, sends `fill-otp` to that frame only, and deletes the intent. A second field on a later page gets nothing. + +Alternative considered: keeping the code itself in the intent. Rejected: a code is a credential for 30 seconds, and computing it fresh is cheap. Alternative considered: `chrome.webNavigation` to track the next page. Rejected: it needs a new permission, and an in-page step (a single-page app swapping the form) never navigates. + +## Security and zero-knowledge + +Nothing changes in what the server sees: ciphertext and the unencrypted `name` and `url` index fields, as ADR-003 and `browser-extension-autofill` require. The master password, the derived key and the vault `CryptoKey` stay in the worker's memory only. + +The code intent in `storage.session` holds a tab id, a registrable domain, a secret id and an expiry. None of these is secret material. The seed is decrypted transiently in the worker when the code is computed, exactly as today (`service-worker.js:163`). A page cannot trigger a code fill on another site, on another tab, after five minutes, or twice. + +Store credentials never reach the extension or the server. The signed packages contain only the bundled source that CI built from the tag. + +## Risks / Trade-offs + +- **The worker can be terminated between the two login steps.** Termination locks the vault, and a locked vault never fills. The user then unlocks and reads the code in the popup, as today. Keeping the worker alive longer would keep the key in memory longer, which this change does not do. +- **Store review can take days and can reject a release.** Releases are tagged separately from the app (`extension-v*`), so an app release never waits on a store. +- **Wide host permissions draw reviewer scrutiny.** They are needed to detect login fields on any site; the justification says so. +- **A site's code field may not match `OTP_SELECTORS`.** The clipboard copy stays as the fallback. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests use the existing `tests/extension` fixtures; a static test page with a two-step login form is added for the Playwright extension flow. + +## Migration + +None on the server: no table, no column, no `` bump. The extension's own manifest `version` moves with each `extension-v*` tag. diff --git a/openspec/changes/clients-extension-store-release/proposal.md b/openspec/changes/clients-extension-store-release/proposal.md new file mode 100644 index 000000000..01897ad9c --- /dev/null +++ b/openspec/changes/clients-extension-store-release/proposal.md @@ -0,0 +1,69 @@ +--- +kind: code +--- + +# Publish the browser extension in the stores and fill a one-time code on the next step + +## Why + +The Keepiq browser extension fills logins and one-time codes in code, but no user can install it without building it from source and loading it unpacked. There is no release or store pipeline: `.github/workflows/` holds `cli-release.yml` for the Go CLI and nothing for `browser-extension/`. And the one-time code is only filled when the code field is already on the page at fill time, while most sites ask for the code on the page after the password. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| clients-01 | Fill in logins on websites through a browser extension. | Autofill works in code: the popup lists owned secrets matching the site, decrypts in the worker and fills on click. The extension is not packaged or published, and matching only covers secrets the user owns, not shared or team-folder secrets. | +| clients-04 | Fill in the current one-time code together with the login. | After filling a login the worker looks for a totp-typed secret on the same host, fills a detected one-time-code field and copies the code with a 30 second clipboard clear. The OTP field has to be on the page at fill time, and the extension is not distributed. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Both rows are partial. + +- clients-01. Built: URL matching (`browser-extension/src/popup/popup.js:45` to `lib/Controller/ExtensionController.php:180`), decrypt in the worker (`browser-extension/src/background/service-worker.js:113`) and fill (`browser-extension/src/content/content-script.js:95`). Missing, from the decision: a packaged, signed extension in the browser stores, and matching over shared and team-folder secrets. The second half does not hold up against the code and is not specified here: a shared or team-folder copy is a `Secret` row owned by the recipient (`lib/Service/RecipientSecretCopyService.php:112` sets `ownerType` `user` and `:113` sets `ownerId` to the recipient; team folders create their copies through the same service at `lib/Service/TeamFolderShareService.php:295`), so the owner-scoped match at `lib/Controller/ExtensionController.php:195` already returns them. +- clients-04. Built: filling a code field present at fill time (`browser-extension/src/background/service-worker.js:137` to `:141`, `browser-extension/src/content/content-script.js:115`) and copying the code. Missing, from the decision: filling a one-time code field that appears after the login step. + +### Demand + +No demand row. + +### Competitors rated yes + +clients-01: + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/autofill/services/autofill.service.ts:481 doAutoFill, :712 doAutoFillActiveTab; apps/browser/src/manifest.v3.json:137 autofill_login shortcut ..." +- 1Password: "https://support.1password.com/save-fill-passwords/ : 'After you've saved your username and password for a website, 1Password can fill them'" +- Passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/all/background_page/pagemod/webIntegrationPagemod.js injects the web integration; src/all/background_page/controller/autofill/AutofillController.js:149 fillCredentials ..." +- Keeper: "https://docs.keeper.io/user-guides/browser-extensions : KeeperFill 'Autofill Your Passwords' on websites" +- Nextcloud Passwords: "not in the cloned repos, docs rating kept: marius-wieschollek/passwords@2026.9.0 src/js/Services/AppStoreService.js:19 ... https://git.mdns.eu/nextcloud/passwords/-/wikis/Administrators/Feature-Comparison ..." + +clients-04: + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/autofill/services/autofill.service.ts:104 TotpService injected, :427 autoCopyTotp$ copies the current code to the clipboard after filling ..." +- 1Password: "https://support.1password.com/one-time-passwords/ : '1Password automatically fills your one-time password'" +- Passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/all/background_page/controller/autofill/AutofillController.js:99 reads totp from the decrypted secret, :101 fillCredentials with username, password and totp ..." +- Keeper: "https://docs.keeper.io/user-guides/browser-extensions#autofilling-2fa-codes : 'Upon autofilling your username and password via KeeperFill, when prompted, a stored two-factor code will also be autofilled'" + +## What Changes + +- A release pipeline for `browser-extension/`: tested, built per browser, packed, and on an `extension-v*` tag submitted to the Chrome Web Store, Firefox Add-ons (AMO) and Microsoft Edge Add-ons, with the packages attached to a GitHub release. +- `browser-extension/build.mjs` gains a `--target chrome|firefox` option that writes the right manifest for each browser. +- Store listings with a privacy policy, a justification per permission, and user-facing copy that follows the writing rules. The unused `scripting` permission is removed. +- A version handshake: the pair response carries the Keepiq server version, and the extension tells the user when the server is too old for it. +- Enterprise rollout documentation: force-install by store id through Chrome and Edge policy, and a signed Firefox package on the GitHub release. +- After a login fill, the extension remembers a short-lived, one-shot intent to fill the one-time code on that tab. When a code field appears on the same site within five minutes, the extension computes the current code and fills it. + +## Capabilities + +### New Capabilities + +- `extension-store-release`: packaging, signing, publishing and versioning of the browser extension. + +### Modified Capabilities + +- `extension-totp-autofill`: adds a requirement for filling the one-time code on the step after the login. + +## Impact + +- **Backend**: `ExtensionController::pair()` adds the server version to its response (`lib/Controller/ExtensionController.php:123` already returns capabilities). No new route. +- **Frontend**: extension only: `build.mjs`, `manifest.json`, `service-worker.js`, `content-script.js`, the popup. The web app is untouched. +- **Database**: none. No migration and no `` bump for the server. +- **Security**: store credentials live in a protected GitHub environment; the pending code intent holds no seed and no code; the zero-knowledge model of `browser-extension-autofill` is unchanged. +- **Cross-app**: none. diff --git a/openspec/changes/clients-extension-store-release/specs/extension-store-release/spec.md b/openspec/changes/clients-extension-store-release/specs/extension-store-release/spec.md new file mode 100644 index 000000000..ca74cb472 --- /dev/null +++ b/openspec/changes/clients-extension-store-release/specs/extension-store-release/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: The extension is published in the browser stores + +The system MUST publish the browser extension to the Chrome Web Store, Firefox Add-ons and Microsoft Edge Add-ons from a CI job that runs on an `extension-v` tag, and MUST attach the built packages, including a signed Firefox package for self-hosting, to a GitHub release for that tag. The publishing job MUST run in a protected GitHub environment that a maintainer approves, and store credentials MUST NOT be readable by any pull-request workflow. + +#### Scenario: A tag ships to the stores + +- **GIVEN** a maintainer pushes the tag `extension-v1.2.0` +- **WHEN** a maintainer approves the `extension-stores` environment for the release job +- **THEN** the job MUST submit the Chrome package to the Chrome Web Store, the Firefox package to Firefox Add-ons and the Chrome package to Edge Add-ons +- **AND** the GitHub release `extension-v1.2.0` MUST hold the Chrome zip, the Firefox package and the signed unlisted Firefox package + +#### Scenario: A pull request cannot reach store credentials + +- **GIVEN** a pull request that changes `browser-extension/manifest.json` +- **WHEN** the extension workflow runs for that pull request +- **THEN** it MUST build, lint and test both targets +- **AND** it MUST NOT have access to any `extension-stores` secret + +### Requirement: Packages are built from source per browser and reproducibly + +The system MUST build a Chrome manifest with `background.service_worker` and a Firefox manifest with `browser_specific_settings.gecko.id` and `background.scripts` from one source tree, MUST take the manifest `version` from the release tag, and MUST fail the workflow when two builds of the same commit produce different packages. + +#### Scenario: Two builds match + +- **GIVEN** the extension workflow on any commit +- **WHEN** it builds the Firefox target twice +- **THEN** the two package hashes MUST be equal, or the workflow MUST fail + +### Requirement: Listings carry a privacy policy and least permissions + +The system MUST ship each store listing with a privacy policy that states the extension sends the server only ciphertext and the unencrypted `name` and `url` index fields, and stores only the pairing in extension storage. The manifest MUST NOT request a permission that no extension code uses. + +#### Scenario: An unused permission fails the build + +- **GIVEN** a manifest that lists `scripting` +- **WHEN** the extension test suite checks every requested permission against its call sites in `browser-extension/src/` +- **THEN** the test MUST fail naming `scripting` + +### Requirement: The extension checks the server version on pairing + +The system MUST return the Keepiq server version from `POST /api/v1/extension/pair`, and the extension MUST show an update message instead of the vault view when the server version is below the extension's minimum. + +#### Scenario: Old server, new extension + +- **GIVEN** a store-updated extension whose minimum server version is newer than the paired Keepiq server +- **WHEN** a vault owner opens the popup +- **THEN** the popup MUST say the Keepiq server needs an update +- **AND** the extension MUST NOT call `GET /api/v1/extension/match` + +### Requirement: Organisations can force-install the extension + +The system MUST document how an administrator force-installs the extension by store id through Chrome and Edge enterprise policy, and how to deploy the signed Firefox package through Firefox enterprise policy. + +#### Scenario: Chrome policy install + +- **GIVEN** an administrator who adds the Keepiq store id to `ExtensionInstallForcelist` +- **WHEN** a managed Chrome profile starts +- **THEN** the Keepiq extension MUST be installed and shown in the toolbar without user action diff --git a/openspec/changes/clients-extension-store-release/specs/extension-totp-autofill/spec.md b/openspec/changes/clients-extension-store-release/specs/extension-totp-autofill/spec.md new file mode 100644 index 000000000..1a5a804aa --- /dev/null +++ b/openspec/changes/clients-extension-store-release/specs/extension-totp-autofill/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: One-time code fill on the step after the login + +After filling a login that has a matched `totp` secret, the extension MUST keep a one-shot fill intent holding only the tab id, the login's registrable domain, the `totp` secret id and an expiry five minutes out, in `chrome.storage.session` and never in `storage.local` or `storage.sync`. When a visible one-time-code field appears later in that tab, the extension MUST compute the current code and fill it only if the field's frame has the same registrable domain, the intent has not expired, and the vault is unlocked. It MUST then delete the intent. Lock and unpair MUST delete every intent. + +#### Scenario: The code page follows the password page + +- **GIVEN** an unlocked extension and a vault owner who fills a login on `example.com` whose `totp` secret matches `example.com` +- **WHEN** the site shows a one-time-code field on the next page within five minutes +- **THEN** the extension MUST fill that field with the current code +- **AND** a code field on any later page MUST NOT be filled + +#### Scenario: Another site cannot collect the code + +- **GIVEN** a pending intent for `example.com` in a tab +- **WHEN** that tab navigates to `attacker.example.net` and the page shows a one-time-code field +- **THEN** the extension MUST NOT compute or fill a code + +#### Scenario: A locked vault fills nothing + +- **GIVEN** a pending intent for `example.com` +- **WHEN** the vault locks before the code field appears +- **THEN** the intent MUST be deleted and no code MUST be filled +- **AND** the popup MUST still offer the code once the vault owner unlocks + +#### Scenario: The intent never holds secret material + +- **GIVEN** a pending intent after a login fill +- **WHEN** the test suite reads `chrome.storage.session` +- **THEN** the stored intent MUST contain no seed and no code diff --git a/openspec/changes/clients-extension-store-release/tasks.md b/openspec/changes/clients-extension-store-release/tasks.md new file mode 100644 index 000000000..c895d39fa --- /dev/null +++ b/openspec/changes/clients-extension-store-release/tasks.md @@ -0,0 +1,38 @@ +# Tasks: extension store release and one-time code on the next step + +## 1. Build per browser + +- [ ] 1.1 Add `--target chrome|firefox` to `browser-extension/build.mjs`, writing the Firefox manifest keys from D1 and a version taken from an `EXTENSION_VERSION` variable. Verify: a vitest in `tests/extension/` builds both targets into a temp dir and asserts the Firefox manifest has `browser_specific_settings.gecko.id` and `background.scripts`, and the Chrome manifest has `background.service_worker`. +- [ ] 1.2 Remove the unused `scripting` permission and replace the manifest `description` with store copy written with the writing skill. Verify: `grep -rn "chrome.scripting" browser-extension/src` returns nothing, and the dash grep on `manifest.json` is clean. + +## 2. Release pipeline + +- [ ] 2.1 Add `.github/workflows/extension-release.yml` for pull requests and pushes touching `browser-extension/**`, `src/crypto/**` or `src/totp/**`: vitest `tests/extension`, both builds, a second build with a hash comparison, `web-ext lint`, and zip artefacts. Verify: the workflow runs green on the pull request that adds it. +- [ ] 2.2 Add the tag job for `extension-v*` bound to the protected `extension-stores` environment: Chrome Web Store upload and publish, AMO listed signing with the source archive, Edge Add-ons submission, unlisted Firefox signing, and a GitHub release with all packages. Verify: a dry run on a pre-release tag against the stores' test or unlisted channels, checked by hand. +- [ ] 2.3 Document the store credentials each secret holds, with placeholder values only, and who approves the environment. Verify: manual review; gitleaks passes. + +## 3. Store listings and rollout + +- [ ] 3.1 Write the privacy policy page and one justification per permission in `docs/`, with the writing skill. Verify: manual review against the writing rules and the stores' listing checklists. +- [ ] 3.2 Document enterprise rollout: Chrome and Edge `ExtensionInstallForcelist` by store id, and the signed Firefox package with Firefox enterprise policy. Verify: manual install through policy on one Chrome and one Firefox profile. + +## 4. Version handshake + +- [ ] 4.1 Add `serverVersion` to the `pair()` response in `lib/Controller/ExtensionController.php`. Verify: PHPUnit `ExtensionControllerTest` asserts the field equals the installed app version. +- [ ] 4.2 Add a minimum server version constant to the extension and an "update your server" state in the popup. Verify: vitest with a mocked pair response below the minimum asserts the popup shows the update state and sends no match request. + +## 5. One-time code on the next step + +- [ ] 5.1 After a login fill with a matched `totp` secret, write the one-shot intent to `chrome.storage.session` in `service-worker.js`, and clear all intents on lock and unpair. Verify: vitest with a mocked `chrome.storage.session` asserts the stored intent holds no seed and no code, and that lock clears it. +- [ ] 5.2 In `content-script.js`, watch for a visible code field on load and through a throttled `MutationObserver`, and send `otp-field-detected` once per field. Verify: vitest with a jsdom page that inserts the field after a delay asserts exactly one message. +- [ ] 5.3 In the worker, fill only when the tab, the registrable domain, the expiry and the unlocked state all match, then delete the intent. Verify: vitest covers a fill on the same site, and refusals for another tab, another site, an expired intent, a locked vault and a second field. +- [ ] 5.4 Add a Playwright flow with the unpacked Chrome build: a vault owner fills a login on a two-step test page and the code field on step two is filled. Verify: the Playwright spec passes locally and in the E2E job. + +## Acceptance criteria + +- A tagged `extension-v*` release produces signed packages for Chrome, Firefox and Edge from CI, attached to a GitHub release, after a maintainer approves the store job. +- Two builds of the same commit produce identical packages. +- No store credential is readable from a pull-request workflow. +- The extension tells the user when the paired server is older than it supports. +- The one-time code fills on the next login step on the same site within five minutes, at most once, and only while the vault is unlocked. +- The pending code intent never holds a seed or a code. diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/.openspec.yaml b/openspec/changes/clients-extension-unlock-lock-and-accounts/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/design.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/design.md new file mode 100644 index 000000000..81e791667 --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/design.md @@ -0,0 +1,90 @@ +# Design: extension biometric unlock, chosen lock delay, and several accounts + +## Context + +Code at development `4c214a9d`: + +- `browser-extension/src/lib/api.js:13` stores one pairing (`{ url, user, appPassword }`) under the key `keepiq.config`; `loadConfig()` (`:16`) returns it or null. Every API call takes that one config. +- `browser-extension/src/lib/vault.js:27` to `:30` keeps one `cryptoKey`, `publicKey`, `suiteId` and `idleTimer` in module scope. `unlock()` (`:49`) fetches the active suite and calls `decryptPrivateKey(suite.privateKey, masterPassword)`; `armIdleLock()` (`:114`) sets the timer. +- `browser-extension/src/background/service-worker.js:26` fixes `DEFAULT_IDLE_MINUTES = 15`; `idleMs()` (`:31`) reads `config.idleMinutes`, which nothing writes. `:254` locks on the OS `locked` idle state. `getState()` (`:42`) reports one `user` and one `url`. +- `browser-extension/src/popup/popup.js:17` switches three views (pair, locked, unlocked); `:182` sends the master password to the worker with `send('unlock', { masterPassword })`. +- `browser-extension/src/crypto/index.js` re-exports `decryptPrivateKey` but not `decryptPrivateKeyWithRawKey` or `deriveUnlockKeyRaw` (both in `src/crypto/aes.js:104` and `:132`), nor the PRF helpers in `src/crypto/passkey.js` (`deriveKekFromPrf` `:72`, `wrapUnlockKey` `:100`, `unwrapUnlockKey` `:119`). +- The web app's passkey unlock: `src/store/modules/passkey.js:94` `enroll(masterPassword, label)` and `:193` `unlockWithPasskey()`, with `RP_ID = window.location.hostname` (`:26`) and `userVerification: 'preferred'`. The server side is `lib/Controller/PasskeyController.php` (`create` `:137`, `loginOptions` `:180`) and `lib/Service/PasskeyService.php` (`enroll` `:95`, `loginOptions` `:134` filtering on `unlock_key_epoch`, `markStaleOnPasswordChange` `:205`, `deleteAllOnRotation` `:217`). The table is `passkey_credentials` in `lib/Migration/Version001000Date20260908000000.php:487`. Routes are `appinfo/routes.php:241` to `:246`. +- The passkey provider already opens a small extension window for a consent step with `chrome.windows.create` (`browser-extension/src/passkey/orchestrator.js:45`). +- Admin session settings live in `src/components/settings/SessionTimeoutSection.vue`; `lib/Service/AdminSettingsService.php:55` allows web session timeouts `session`, `10min`, `30min`. + +## Goals / Non-Goals + +**Goals:** + +- A user chooses the extension's idle lock delay, within an administrator's maximum. +- A user pairs up to five accounts on one or more servers and switches between them in one click. +- A user unlocks the extension with a fingerprint or face through a platform passkey with PRF, where the browser allows it. + +**Non-Goals:** + +- Biometric unlock in the Go CLI. The row names the extension; the CLI stays on the master password. +- Showing candidates from several accounts at once. Matching and filling use the active account; a merged view is a later change. +- Biometric unlock through a native helper app (the Bitwarden desktop route). Keepiq has no native app (`openspec/specs/mobile-pwa/spec.md:11`). +- Remembering an unlocked state across a browser restart. + +## Decisions + +### D1: The idle delay is a per-account setting with an administrator maximum + +The popup's new settings view offers 1, 5, 15, 30, 60 and 240 minutes and stores the choice as `idleMinutes` on the account in `storage.local` (not sensitive). On every unlock the worker calls a new `GET /api/v1/extension/policy`, which returns `maxIdleMinutes` from the app config key `extension_max_idle_minutes` (default 240, set in `SessionTimeoutSection.vue`), and clamps the choice to it. The lock on OS lock, worker termination and manual lock stays unconditional. + +Alternative considered: reusing the web app's `session_timeout` preference. Rejected: its values (`session`, `10min`, `30min`) describe a browser tab, and a user may want a shorter delay in the extension, which fills forms on any site. + +### D2: An account list replaces the single pairing + +Storage moves to `keepiq.accounts` (a list of `{ id, url, user, appPassword, label, idleMinutes }`) and `keepiq.activeAccountId`. On worker start, an existing `keepiq.config` becomes the first account and the old key is removed. The limit is five accounts. + +`vault.js` keeps a map from account id to `{ cryptoKey, publicKey, suiteId, idleTimer }`. Each account locks on its own timer; OS lock and worker termination lock them all. The blob cache from `doMatch()` is keyed by account. Every worker message that reads or fills carries the account id, and the worker refuses a fill for a secret id that came from another account's match. A captured login is saved to the active account, and the save prompt names that account. + +The popup header shows the active account (`user@host`) with a menu listing the others, their lock state, and "Add account". Unpair removes one account. + +Alternative considered: one account unlocked at a time, switching locks the previous one. Rejected: a user switching back and forth would re-enter a password every time, which invites weak master passwords. + +### D3: Biometric unlock is a PRF passkey owned by the extension + +The extension enrols its own platform credential; it cannot use the web app's, because that one is bound to the Nextcloud host as relying party. The ceremony runs in a small extension window opened with `chrome.windows.create`, like the passkey consent window, because the OS prompt takes focus and would close the popup. The relying party is the extension's own origin. The request asks for `authenticatorAttachment: 'platform'`, `userVerification: 'required'` and the `prf` extension. + +Enrolment: the user enters the master password once in that window. The window derives the raw unlock key with `deriveUnlockKeyRaw` from the suite envelope's salt, checks it against the envelope, runs `create()` and a `get()` with a fresh PRF salt, derives the key-encryption key with `deriveKekFromPrf`, wraps the raw unlock key with `wrapUnlockKey`, and asks the worker to post the credential. This is the web app's recipe (`src/store/modules/passkey.js:94`), reused, not re-implemented. + +Unlock: the window fetches the login options through the worker, runs `get()` with the stored PRF salt, derives the key-encryption key, unwraps the raw unlock key, and sends it to the worker over the extension's internal messaging, the same channel the popup already uses for the master password. The worker calls `decryptPrivateKeyWithRawKey`, imports the non-extractable key, and the window closes. + +Alternative considered: keeping the wrapped key only in extension storage. Rejected: the user could not see or revoke it from the web app, and it would escape the server's `unlock_key_epoch` invalidation on a password change or rotation. + +### D4: Server-side, extension credentials sit next to web credentials + +`passkey_credentials` gains `client_kind` (`web` default, or `extension`) and `rp_id`. `POST /api/v1/passkeys` accepts both; `GET /api/v1/passkeys/login-options` takes `client` and `rpId` query parameters and returns only matching credentials, so the web app never offers an extension credential and the reverse. `PasskeyManager.vue` labels extension credentials "Browser extension" and revokes them the same way. The existing epoch check and `deleteAllOnRotation()` apply unchanged. + +### D5: Feature detection, not browser lists + +The unlock option appears only when the extension page exposes `PublicKeyCredential`, `isUserVerifyingPlatformAuthenticatorAvailable()` returns true, and enrolment reports `prf.enabled`. A browser that refuses WebAuthn from an extension origin, or an authenticator without PRF, shows the master password form only. Which browsers pass is recorded in the extension README after the implementation checks them. + +## Security and zero-knowledge + +The server stores, per extension credential: the credential id, the PRF salt, the AES-256-GCM wrapped unlock key, the epoch, the label, `client_kind` and `rp_id`. It never receives the master password, the raw unlock key, the PRF output or the key-encryption key (ADR-003, and the `passkey-vault-login` rules). A stolen database row is useless without the user's authenticator and a successful user verification. + +The raw unlock key exists briefly in the unlock window and in the worker, never in `storage.*`. The window closes after the handoff. The worker keeps only the non-extractable `CryptoKey` it already keeps today. + +Extension storage holds, per account: the server URL, the user, the Nextcloud app password, a label and the idle delay. None of it is key material; revoking the app password in Nextcloud cuts the account off, as today. + +Accounts cannot read each other's data: key material, blob caches and timers are keyed by account, and a fill request is checked against the account that produced the match. + +## Risks / Trade-offs + +- **Browser support for WebAuthn in extension pages varies.** D5 hides the option where it fails; the master password always works. +- **A shorter maximum set later by an administrator** takes effect at the next unlock, not immediately. Acceptable: the lock on OS lock is unconditional. +- **Five unlocked accounts hold five keys in memory.** Each has its own timer, and one OS lock clears them all. +- **Storage migration from `keepiq.config`** runs once; a test covers an upgrade from the old shape. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests use a virtual WebAuthn authenticator (Chrome DevTools protocol in Playwright) and the existing `tests/extension` fixtures. + +## Migration + +A migration step adds `client_kind` (`STRING(16)`, not null, default `web`) and `rp_id` (`STRING(255)`, nullable) to `keepiq_passkey_credentials`. Existing rows become `web`. The `` in `appinfo/info.xml` must bump. The extension migrates its own `keepiq.config` to `keepiq.accounts` on first start after the update. diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/proposal.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/proposal.md new file mode 100644 index 000000000..78805de5a --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/proposal.md @@ -0,0 +1,73 @@ +--- +kind: code +--- + +# Extension unlock with a fingerprint or face, a chosen lock delay, and several accounts + +## Why + +The browser extension unlocks only with the master password, locks after a fixed 15 minutes, and knows one Nextcloud account at a time. The web app already unlocks with a platform passkey, users ask to pick their own lock delay, and people with a work and a personal server have to unpair to switch. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| clients-06 | The extension locks itself automatically. | The extension locks after 15 idle minutes, on OS or browser lock, on worker termination and on demand. The idle period cannot be changed because nothing ever writes config.idleMinutes, and the extension is not distributed. | +| clients-21 | Switch between several accounts or servers in the same app or extension. | The extension pairs with one Nextcloud account at a time; switching means unpairing. | +| crypto-09 | Unlock with a fingerprint or face scan on your device. | Fingerprint or face unlock works in the web app only by enrolling a platform passkey (Touch ID, Windows Hello) whose browser supports PRF. The browser extension and CLI have no biometric unlock. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +- clients-06 is partial. Built: the fixed 15 minute idle lock (`browser-extension/src/background/service-worker.js:26` and `:37`, `browser-extension/src/lib/vault.js:114`), the lock on OS lock (`service-worker.js:254`) and the Lock button (`browser-extension/src/popup/popup.js:197`). Missing, from the decision: a user-chosen idle period. The worker reads `config.idleMinutes` (`service-worker.js:33`) but nothing writes it. Distribution is covered by the change `clients-extension-store-release`. +- clients-21 is not built: `browser-extension/src/lib/api.js:13` to `:31` stores one config under one key. +- crypto-09 is partial. Built: the web app's PRF passkey unlock (`src/components/PasskeyManager.vue:25`, `src/store/modules/passkey.js:193`). Missing, from the decision: fingerprint or face unlock in the browser extension. The popup unlocks with the master password only (`popup.js:182`). + +### Demand + +- clients-21, featureRequest: https://community.bitwarden.com/t/account-switching/716 + +No demand row for clients-06 or crypto-09. + +### Competitors rated yes + +clients-06: + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/key-management/vault-timeout/vault-timeout.service.ts; libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts:59 checkVaultTimeout ..." +- 1Password: "https://support.1password.com/unlock-auto-lock/ : idle auto-lock, and 'when you quit your browser, 1Password will always lock'" +- Passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/all/background_page/service/auth/startLoopAuthSessionCheckService.js:19 checks the server session every 60 s and logs the extension out when it expired ..." +- Keeper: "https://docs.keeper.io/user-guides/browser-extensions#stay-logged-in : 'Inactivity Logout Timer which automatically logs you out of Keeper after a period of inactivity'; admin Logout Timer policy" + +clients-21: + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/auth/popup/account-switching/account-switcher.component.ts:40 switcher page; apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts:72 ACCOUNT_LIMIT, :93 add account entry ..." +- 1Password: "https://support.1password.com/multiple-accounts/ : add multiple accounts to the apps and browser extension, see all items at once or 'Switch to a specific account'." +- Keeper: "https://docs.keeper.io/user-guides/tips-and-tricks/personal-and-business-vaults : switch between business and personal accounts in the web vault, browser extension and mobile apps ('Switch Account', 'Add Account')." + +crypto-09: + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/desktop/src/key-management/biometrics/main-biometrics.service.ts, native-v2/os-biometrics-linux.service.ts; apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts (extension unlock via desktop) ..." +- 1Password: "https://support.1password.com/windows-hello/ and https://support.1password.com/face-id/ : unlock with face, fingerprint, Touch ID" +- Keeper: "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#device-biometrics : 'Keeper natively supports Windows Hello, Touch ID, Face ID and Android biometrics'" + +## What Changes + +- The popup gets a settings view where the user picks the idle lock delay per account: 1, 5, 15 (default), 30, 60 or 240 minutes. An administrator can set a maximum, which the extension enforces. +- The extension holds up to five paired accounts, each with its own server, lock state, idle timer and settings. The popup header switches between them; matching and filling use the active account only. +- The extension can enrol a platform passkey with the WebAuthn PRF extension (Touch ID, Windows Hello, a phone or laptop fingerprint or face sensor) and then unlock with it instead of the master password. The wrapped unlock key is stored server-side next to the web app's passkey envelopes, so the web app lists and revokes it. + +## Capabilities + +### New Capabilities + +- `extension-biometric-unlock`: PRF passkey enrolment and unlock inside the browser extension. +- `extension-account-switching`: several paired accounts in one extension. + +### Modified Capabilities + +- `browser-extension-autofill`: adds a requirement for the user-chosen idle lock period with an administrator maximum. + +## Impact + +- **Backend**: `passkey_credentials` gains `client_kind` and `rp_id`; `PasskeyService::enroll()` and `loginOptions()` filter by them; the org policy response carries `extensionMaxIdleMinutes`. +- **Frontend**: extension popup (settings view, account switcher, biometric unlock button), a new extension window for the WebAuthn ceremony, `vault.js` and `api.js` refactored to per-account state. In the web app, `PasskeyManager.vue` labels extension credentials. +- **Database**: two new columns on `keepiq_passkey_credentials`; a migration and a `` bump. +- **Security**: the server stores only a PRF-wrapped unlock key, as for the web app; the master password, the raw unlock key and the PRF output never reach it. Accounts are isolated from each other in the worker. +- **Cross-app**: none. diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md new file mode 100644 index 000000000..6f668fb25 --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md @@ -0,0 +1,18 @@ +## ADDED Requirements + +### Requirement: User-chosen idle lock period with an administrator maximum + +The extension MUST let the user choose the idle lock period per paired account from 1, 5, 15, 30, 60 and 240 minutes, with 15 as the default, and MUST store the choice in extension storage. On every unlock the extension MUST read `maxIdleMinutes` from `GET /api/v1/extension/policy` and MUST use the lower of the user's choice and that maximum. The lock on OS or browser lock, on worker termination and on manual lock MUST stay in force whatever the period. + +#### Scenario: A user picks five minutes + +- **GIVEN** a vault owner with an unlocked extension and an administrator maximum of 240 minutes +- **WHEN** they choose 5 minutes in the popup settings view and leave the browser idle for 5 minutes +- **THEN** the extension MUST lock and the next fill MUST ask for an unlock + +#### Scenario: The administrator maximum wins + +- **GIVEN** a vault owner who chose 240 minutes +- **WHEN** an administrator sets the extension maximum to 30 minutes in the Keepiq admin settings and the owner next unlocks the extension +- **THEN** the extension MUST lock after 30 idle minutes +- **AND** the popup settings view MUST show that 60 and 240 minutes exceed the organisation's maximum diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md new file mode 100644 index 000000000..f10413fda --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Up to five paired accounts in one extension + +The extension MUST let a user pair up to five Nextcloud accounts, on the same or different servers, each with its own app password, lock state, idle timer and settings, and MUST refuse a sixth pairing with a clear message. An extension paired before this change MUST keep its pairing as the first account without asking the user to pair again. + +#### Scenario: Work and personal servers side by side + +- **GIVEN** a user who has paired `alice@cloud.work.example` in the extension +- **WHEN** they choose "Add account" in the popup and pair `alice@cloud.home.example` +- **THEN** the popup header MUST list both accounts +- **AND** the first account MUST keep its lock state + +#### Scenario: An existing pairing survives the update + +- **GIVEN** an extension paired under the old single-pairing storage +- **WHEN** the updated extension starts +- **THEN** the pairing MUST appear as the first account and the old storage key MUST be removed + +### Requirement: Switching and isolation between accounts + +The extension MUST show the active account in the popup header and switch to another account in one click. Matching, filling, the one-time code and save prompts MUST use the active account only. Key material, cached blobs and idle timers MUST be kept per account, and the worker MUST refuse to fill a secret id that did not come from the active account's own match. + +#### Scenario: Switching changes the candidates + +- **GIVEN** two unlocked accounts with different logins for `example.com` +- **WHEN** the user switches the active account in the popup header while on `example.com` +- **THEN** the candidate list MUST show only the newly active account's logins + +#### Scenario: A cross-account fill is refused + +- **GIVEN** a match result from account A +- **WHEN** a fill message arrives for one of those secret ids while account B is active +- **THEN** the worker MUST refuse the fill and decrypt nothing + +#### Scenario: Each account locks on its own timer + +- **GIVEN** account A with a 5 minute idle period and account B with a 60 minute idle period, both unlocked +- **WHEN** 5 idle minutes pass +- **THEN** account A MUST be locked and account B MUST stay unlocked +- **AND** an OS lock MUST lock both diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md new file mode 100644 index 000000000..4fae2f735 --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Enrol a platform passkey for extension unlock + +The extension MUST let a user with a known master password enrol a platform authenticator (fingerprint or face) that supports the WebAuthn `prf` extension, with the extension's own origin as relying party and `userVerification` `required`. The extension MUST wrap the raw vault unlock key with an AES-256-GCM key derived from the PRF output and MUST send the server only the credential metadata, the PRF salt and the wrapped key, stored with `client_kind` `extension`. The master password, the raw unlock key and the PRF output MUST NOT reach the server. + +#### Scenario: Enrolment stores only a wrapped key + +- **GIVEN** a vault owner with a paired extension on a laptop with Windows Hello +- **WHEN** they choose "Unlock with fingerprint or face" in the popup, confirm their master password and pass the Windows Hello prompt +- **THEN** `POST /api/v1/passkeys` MUST receive a credential with `client_kind` `extension`, a PRF salt and a wrapped unlock key +- **AND** the request MUST NOT contain the master password, the raw unlock key or the PRF output + +### Requirement: Unlock the extension with the enrolled passkey + +The extension MUST let the user unlock with the enrolled credential: it MUST request the login options for `client` `extension` and its own relying party, run the WebAuthn ceremony with the stored PRF salt, unwrap the raw unlock key in an extension page, hand it to the worker over internal extension messaging, and import the private key as a non-extractable `CryptoKey`. The raw unlock key MUST NOT be written to extension storage. The master password MUST remain available as an unlock method. + +#### Scenario: Fingerprint unlock + +- **GIVEN** a vault owner who enrolled a fingerprint and whose extension is locked +- **WHEN** they choose the fingerprint unlock in the popup and touch the sensor +- **THEN** the extension MUST unlock and list the matching logins for the current site +- **AND** no extension storage area MUST contain the raw unlock key afterwards + +#### Scenario: A changed master password retires the credential + +- **GIVEN** a vault owner with an enrolled extension credential +- **WHEN** they change their master password in the web app +- **THEN** the login options MUST no longer offer the extension credential +- **AND** the popup MUST fall back to the master password form + +### Requirement: Extension credentials are visible and revocable in the web app + +The web app's passkey list MUST show extension credentials labelled as browser extension credentials, and the owner MUST be able to revoke one through `DELETE /api/v1/passkeys/{id}`. The web app MUST NOT offer an extension credential on its lock screen, and the extension MUST NOT be offered a web credential. + +#### Scenario: Revoking a lost laptop's extension credential + +- **GIVEN** a vault owner whose laptop with an enrolled extension credential is lost +- **WHEN** they revoke that credential in the passkey list of the web app +- **THEN** the extension on that laptop MUST no longer be able to unlock with it + +### Requirement: Biometric unlock is offered only where it works + +The extension MUST offer biometric unlock only when its page exposes WebAuthn, a user-verifying platform authenticator is available, and enrolment reports PRF support. Otherwise it MUST show the master password form only, without an error. + +#### Scenario: Browser without WebAuthn in extension pages + +- **GIVEN** a browser that refuses a WebAuthn ceremony from an extension page +- **WHEN** the vault owner opens the locked popup +- **THEN** the popup MUST show the master password form and no biometric option diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/tasks.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/tasks.md new file mode 100644 index 000000000..7495b91fa --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/tasks.md @@ -0,0 +1,29 @@ +# Tasks: extension biometric unlock, chosen lock delay, and several accounts + +## 1. Idle lock delay + +- [ ] 1.1 Add `GET /api/v1/extension/policy` returning `maxIdleMinutes` from app config `extension_max_idle_minutes` (default 240), with `#[NoAdminRequired]`, and a field for it in `SessionTimeoutSection.vue`. Verify: PHPUnit `ExtensionControllerTest` for the default and a set value; vitest for the admin field. +- [ ] 1.2 Add the popup settings view with the six delays, store `idleMinutes` per account, and clamp to `maxIdleMinutes` on every unlock in the worker. Verify: vitest asserts the stored value, the clamp, and that the timer uses the clamped value. + +## 2. Several accounts + +- [ ] 2.1 Move `api.js` storage to `keepiq.accounts` and `keepiq.activeAccountId`, with a one-time migration from `keepiq.config` and a limit of five accounts. Verify: vitest upgrades a stored old config into one account and refuses a sixth pairing. +- [ ] 2.2 Refactor `vault.js` to per-account key state and timers; OS lock and worker restart clear all accounts. Verify: vitest unlocks two accounts, lets one timer expire, and asserts only that account is locked. +- [ ] 2.3 Key the worker's blob cache by account, carry the account id in match, fill and save messages, and refuse a fill for a secret id from another account's match. Verify: vitest asserts the refusal and that a capture is saved to the active account. +- [ ] 2.4 Add the account switcher to the popup header (active account, lock state per account, add and unpair). Verify: vitest renders the switcher for three accounts and switches the active one. + +## 3. Biometric unlock + +- [ ] 3.1 Add `client_kind` and `rp_id` to `keepiq_passkey_credentials` with a migration and a `` bump; accept them in `PasskeyService::enroll()` and filter `loginOptions()` by `client` and `rpId`. Verify: PHPUnit `PasskeyServiceTest` asserts the web app never receives an extension credential and the reverse. +- [ ] 3.2 Re-export `deriveUnlockKeyRaw`, `decryptPrivateKeyWithRawKey` and the PRF helpers through `browser-extension/src/crypto/index.js`, and add a worker `unlock-raw` message that imports the key from a raw unlock key. Verify: vitest round trip: wrap a raw key, unwrap it, unlock the worker, decrypt a test secret. +- [ ] 3.3 Add the extension unlock window for enrolment and unlock (D3), opened with `chrome.windows.create`, with feature detection (D5). Verify: Playwright with the unpacked Chrome build and a virtual authenticator with PRF: a vault owner enrols, locks, and unlocks with the authenticator. +- [ ] 3.4 Label extension credentials in `src/components/PasskeyManager.vue` and let the owner revoke them there. Verify: vitest renders an extension credential with its label and calls the delete route. +- [ ] 3.5 Check which browsers pass the feature detection and record the result in `browser-extension/README.md`. Verify: manual check on current Chrome, Edge and Firefox. + +## Acceptance criteria + +- A user can pick an idle lock delay in the popup, and the extension never uses a delay above the administrator's maximum. +- A user can pair up to five accounts, switch between them from the popup header, and each account locks on its own timer. +- A fill request can never use a secret from an account other than the one that produced the match. +- Where the browser supports it, a user can unlock the extension with a fingerprint or face through a PRF passkey, and the master password always remains available. +- The server stores only the PRF-wrapped unlock key for an extension credential, and the web app lists and revokes it. diff --git a/openspec/changes/clients-offline-edits/.openspec.yaml b/openspec/changes/clients-offline-edits/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/clients-offline-edits/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/clients-offline-edits/design.md b/openspec/changes/clients-offline-edits/design.md new file mode 100644 index 000000000..aa3a6eb3e --- /dev/null +++ b/openspec/changes/clients-offline-edits/design.md @@ -0,0 +1,93 @@ +# Design: offline edit queue + +## Context + +Code at development `4c214a9d`: + +- `src/store/modules/offline.js` owns the offline state. `syncNow()` fetches `GET /api/v1/offline/manifest` (`lib/Controller/OfflineController.php`, built by `lib/Service/OfflineManifestService.php:88`), seals it with `encryptSnapshot()` (`src/offline/snapshot.js:30`, index fields encrypted with the raw unlock key through `encryptMetadata`) and writes it to IndexedDB (`src/offline/cache.js:62`). `unlockOffline()` unlocks from the cached suite envelope. The lock hook keeps the at-rest snapshot and clears only the in-memory view; `evict()` purges it (called on suite rotation from `src/store/modules/encryptionSuite.js:287` and `:1211`). +- `readOnly` is `servedFromCache` (the `readOnly` getter in `offline.js`); `src/views/SecretList.vue:705` disables writes with it, and `src/App.vue:76` shows the stale-data banner. +- Online edits: `PUT /api/v1/secrets/{id}` (`lib/Controller/SecretController.php:294`) takes ciphertext `key`, `login` and `additionalFields` plus plain `name`, `url`, `typeId`, `folderId`, with no precondition. It answers 423 during a suite migration write lock. `DELETE` is `:344`. +- Shared edits: `src/store/modules/share.js` fetches the write context (`GET /api/v1/secrets/{id}/write-context`, `ShareController::writeContext()`), encrypts the value for each recipient certificate it receives, and calls `PUT /api/v1/secrets/{sourceId}/sync` (`syncAsTeamWriter`). The server authorizes the fan-out on the effective grade (`lib/Service/ShareSyncService.php:167`). +- The web app's own-certificate encryption is `rsaEncrypt` from `src/crypto/rsa.js`, chunked at the RSA-4096 OAEP limit; the hybrid construction (AES-256-GCM content key wrapped with RSA-OAEP) exists in `src/crypto/emergencyEnvelope.js:52`. +- Admin setting `offline_cache_enabled` lives in `lib/Service/AdminSettingsService.php:199` and `:353`, with the UI in `src/components/settings/OfflineCacheSection.vue`. + +## Goals / Non-Goals + +**Goals:** + +- Create, edit, move and delete secrets while offline, with the change visible offline at once. +- Replay every change through the same online code paths, so the server rules (grades, write locks, policies) apply at sync time. +- Never send recipient ciphertext computed from a stale snapshot. +- Never overwrite a newer server change without the user choosing to. + +**Non-Goals:** + +- Offline sharing, unsharing, link shares, sends, team-folder membership, folder create or delete, and attachments. They stay online-only. +- Offline edits in the browser extension or the CLI. +- Automatic merging of two changed versions field by field. +- Background sync while the vault is locked. Replay needs the private key. + +## Decisions + +### D1: Queue what an online save would send, sealed to the owner + +Each queue entry holds: an id, the operation (`create`, `update`, `delete`), the secret id (a client-made UUID for a create), `baseUpdatedAt` (the snapshot's `updatedAt` for that secret), the time queued, the owner's suite id, and a sealed body. The sealed body holds the same `key`, `login` and `additionalFields` ciphertext an online save would send (RSA-OAEP to the owner's own certificate, chunked as today) plus the index fields `name`, `url`, `typeId` and `folderId`, the whole body wrapped in the hybrid envelope from `emergencyEnvelope.js` to the owner's own certificate. The hybrid envelope avoids the RSA chunk limit for long additional fields. + +Sealing to the certificate, not to the unlock key, means a routine master password change on another device does not strand the queue: the private key is the same, only its password wrapping changes. + +Alternative considered: sealing with the raw unlock key, like the snapshot's metadata. Rejected: the unlock key changes with the master password, and the queue must survive that. + +### D2: The fan-out happens at sync time only + +The queue never holds recipient ciphertext. At replay the web app decrypts the entry with the in-memory private key and calls the existing online action: create, update or delete of the owner's row. For a secret with recipients it then asks for the write context now, gets the current recipient list and certificates now, encrypts for each, and calls `PUT /api/v1/secrets/{sourceId}/sync`, exactly as an online edit does. A recipient added or removed while the user was offline is therefore handled correctly, which is the reason the offline cache spec gave for staying read-only. + +### D3: A server precondition catches concurrent changes + +`PUT` and `DELETE /api/v1/secrets/{id}` accept an optional `baseUpdatedAt`. When given and different from the stored `updatedAt`, the server changes nothing and answers `409 Conflict` with the current row (ciphertext and index fields, as a normal read returns). Online clients that do not send it behave as today. + +On a 409 the replay stops for that secret and shows a conflict dialog with both versions decrypted in the browser: "Keep my offline change" (replayed again with the new `baseUpdatedAt`; the server's version stays in version history) or "Keep the server version" (the entry is dropped). An offline delete against a changed secret asks the same question. + +Alternative considered: a new integer revision column. Rejected for now: `updatedAt` already exists, and an offline edit is based on a snapshot minutes or hours old, so a same-second collision is not a practical risk. + +### D4: Coalescing and order + +Entries for one secret coalesce locally: repeated updates keep the earliest `baseUpdatedAt` and the latest body; a create followed by updates stays one create; a create followed by a delete removes both. Replay runs oldest first. A create is replayed before any entry that refers to its folder. + +### D5: Failure states are explicit + +A 403 (for example a write grade removed while offline) or a 404 marks the entry failed, shows it in a "Changes that could not sync" list, and lets the user copy their values (decrypted in the browser) or discard the entry. A 423 (suite migration in progress) or a network error leaves it queued for the next attempt. If the owner row saved but the recipient sync failed, the entry stays as "sync recipients" and retries only that step, which is idempotent. + +### D6: When the replay runs + +Replay starts when the browser is online and the vault is unlocked online, and again on the browser's `online` event while unlocked. The banner shows "N changes waiting to sync" until the queue is empty. Before logout, and before starting a suite rotation, the app asks the user to sync or discard the pending changes; a rotation cannot start while entries are pending. + +If the active suite on the server differs from the entries' suite (a rotation ran on another device), the app asks once for the previous master password to open the entries with the cached old envelope, then replays them under the new certificate, or lets the user discard them. + +### D7: Administrator control, off by default + +A new app config `offline_edits_enabled` (default `false`) sits next to `offline_cache_enabled` in `OfflineCacheSection.vue` and travels in the offline manifest, so an offline client knows the rule. When false, the offline view stays read-only as today; entries already queued still replay on the next online unlock. When offline caching itself is disabled, the next online unlock replays the queue first and then purges the cache and the queue together. + +Alternative considered: on by default. Rejected: existing deployments chose offline caching under a read-only promise, and an upgrade should not change that without an administrator's choice. + +## Security and zero-knowledge + +The server receives only what an online save sends: the owner-row ciphertext, the plain index fields, and at sync time the recipient ciphertext made with recipient certificates fetched at that moment (ADR-003). The master password never leaves the browser; the queue is opened with the in-memory private key. + +At rest in IndexedDB: entries whose body is sealed to the owner's certificate (values and index fields). Plain in each entry: the entry id, the operation, the secret id, `baseUpdatedAt`, the queue time and the suite id. None of these is secret; the snapshot already holds secret ids in plain. A stolen device yields no value or name without the private key, which needs the master password. + +Plaintext exists only in the unlocked page: while the user edits, and briefly at replay while the recipient fan-out is computed. + +## Risks / Trade-offs + +- **A user edits offline, then the owner removes their write grade.** The replay gets 403 and the entry is kept for the user to copy or discard; nothing is lost silently. +- **A long offline period meets many server changes.** Each conflict is a user decision. The dialog shows both versions side by side. +- **Two devices both offline edit the same secret.** The second to sync gets the 409 and decides. +- **Logout with pending changes.** The app warns first; a forced logout keeps the sealed queue for the next login on that browser. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests use the existing development secrets and a Playwright context switched offline. + +## Migration + +None: no table, no column. The new setting is an app config key with a default, so no `` bump is needed. The IndexedDB database gains a queue store in a new schema version of the offline cache database, created on first use. diff --git a/openspec/changes/clients-offline-edits/proposal.md b/openspec/changes/clients-offline-edits/proposal.md new file mode 100644 index 000000000..bec017e81 --- /dev/null +++ b/openspec/changes/clients-offline-edits/proposal.md @@ -0,0 +1,52 @@ +--- +kind: code +--- + +# Edit secrets offline and sync the changes when back online + +## Why + +Keepiq's offline cache lets a field worker read the vault with no network, but every edit needs the server. Someone who rotates a password on site, with no signal, has to remember the new value until they are back online. The offline cache recorded the write queue as a deliberate future change, not as a non-goal. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| clients-19 | Edit items while offline and have the changes sync when you are back online. | Offline mode reads only; edits need the server. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. The offline cache is read-only by design (`openspec/specs/offline-readonly-cache/spec.md`, requirement "Offline mode is strictly read-only"), with the stale-data banner at `src/App.vue:76` and the write guard at `src/views/SecretList.vue:705`. The same spec gives the reason at `:128`: per-recipient share fan-out and sync-on-update re-encrypt against each recipient's current certificate and cannot be safely replayed from a stale snapshot. This change answers that reason: the queue never stores recipient ciphertext; the fan-out is computed at sync time, against certificates fetched at sync time. + +### Demand + +- featureRequest: https://community.bitwarden.com/t/offline-editing-management-of-writeable-vault-items/107 + +### Competitors rated yes + +- 1Password: "https://support.1password.com/sync/ : in the apps data is cached locally so you can view and edit it without an internet connection, and changes reach other devices when you next go online." + +## What Changes + +- When an administrator enables offline edits, a user reading from the offline cache can create, edit, move and delete secrets. Each change goes into a local queue in IndexedDB, sealed to the user's own certificate. +- The offline vault view shows queued changes on top of the cached data, marked "Not synced yet". +- When the browser is online and the vault is unlocked, the web app replays the queue through the normal online paths. For a shared or team-folder secret it fetches the current recipients and certificates at that moment and runs the usual sync-on-update fan-out. +- The server gets an optional `baseUpdatedAt` precondition on `PUT` and `DELETE /api/v1/secrets/{id}`. A changed secret answers `409 Conflict`, and the user chooses to keep their offline version or the server's. +- Sharing, unsharing, link shares, sends, team-folder membership, folders and attachments stay online-only. +- The requirement "Offline mode is strictly read-only" is removed from `offline-readonly-cache` and replaced by the queue's own requirements, including the rule that sharing stays online-only. + +## Capabilities + +### New Capabilities + +- `offline-edit-queue`: the offline change queue, its encryption at rest, replay with a fresh fan-out, conflict handling and administrator control. + +### Modified Capabilities + +- `offline-readonly-cache`: removes the strictly read-only requirement, which the queue supersedes. + +## Impact + +- **Backend**: `SecretController::update()` and `destroy()` accept `baseUpdatedAt` and answer 409 with the current row when it differs; a new admin config key `offline_edits_enabled`, exposed with `offline_cache_enabled` in the admin settings and the offline manifest. +- **Frontend**: `src/offline/` gains a queue store; `src/store/modules/offline.js` gains replay; the secret create and edit dialogs, `SecretList.vue` and the stale-data banner learn the queued state; a conflict dialog; `OfflineCacheSection.vue` gets the new switch. +- **Database**: none. No table, no column, no `` bump; the new setting is app config. +- **Security**: queued values are the same ciphertext an online save sends; index fields are sealed to the owner's certificate; recipient ciphertext is only ever produced at sync time. +- **Cross-app**: none. diff --git a/openspec/changes/clients-offline-edits/specs/offline-edit-queue/spec.md b/openspec/changes/clients-offline-edits/specs/offline-edit-queue/spec.md new file mode 100644 index 000000000..a0b867548 --- /dev/null +++ b/openspec/changes/clients-offline-edits/specs/offline-edit-queue/spec.md @@ -0,0 +1,86 @@ +## ADDED Requirements + +### Requirement: Offline changes go into a sealed local queue + +When the administrator setting `offline_edits_enabled` is true and the vault is served from the offline cache, the web app MUST let the user create, edit, move and delete secrets, and MUST store each change as a queue entry in IndexedDB. Each entry's values and index fields (`name`, `url`, `typeId`, `folderId`) MUST be sealed to the owner's own certificate; only the entry id, the operation, the secret id, `baseUpdatedAt`, the queue time and the suite id MAY be stored in plain. The vault view MUST show queued changes marked as not synced. + +#### Scenario: A field worker rotates a password with no signal + +- **GIVEN** a vault owner reading their vault offline with offline edits enabled +- **WHEN** they edit the password of the secret "Pump station router" in the secret list at /secrets and save +- **THEN** the secret MUST show the new value marked "Not synced yet" +- **AND** the IndexedDB queue MUST hold one entry whose stored form contains neither the new password nor the name "Pump station router" in plain + +#### Scenario: Offline edits disabled keeps the cache read-only + +- **GIVEN** offline edits are disabled by the administrator +- **WHEN** a vault owner reading offline tries to edit a secret +- **THEN** the action MUST be prevented with an explanation that Keepiq is read-only offline + +### Requirement: Sharing and membership actions stay online-only + +The web app MUST keep sharing, unsharing, link shares, sends, team-folder membership, folder create and delete, and attachment actions unavailable while the vault is served from the offline cache, whatever the offline edits setting, and MUST explain why. + +#### Scenario: Share is disabled offline + +- **GIVEN** a vault owner reading offline with offline edits enabled +- **WHEN** they open the share dialog for a secret +- **THEN** the share action MUST be disabled with an explanation that sharing needs a connection + +### Requirement: Replay runs through the online paths with a fresh fan-out + +When the browser is online and the vault is unlocked, the web app MUST replay queued entries oldest first through the same create, update and delete actions an online edit uses. For a secret with recipients it MUST fetch the write context at replay time and encrypt the new value for each recipient certificate returned at that moment. The queue MUST NOT contain, and replay MUST NOT send, recipient ciphertext made before the replay. + +#### Scenario: A recipient added while offline receives the change + +- **GIVEN** a vault owner who edited a shared secret offline, and a second recipient added to that secret by a co-owner in the meantime +- **WHEN** the owner reconnects and unlocks +- **THEN** the replay MUST encrypt the new value for both the original and the new recipient using certificates fetched at replay time +- **AND** both recipients MUST see the new value + +### Requirement: Concurrent server changes are never overwritten silently + +`PUT /api/v1/secrets/{id}` and `DELETE /api/v1/secrets/{id}` MUST accept an optional `baseUpdatedAt`; when it is present and differs from the stored `updatedAt`, the server MUST change nothing and answer `409 Conflict` with the current row. On a 409 the web app MUST stop that entry and let the user keep their offline version or the server version. + +#### Scenario: The secret changed online meanwhile + +- **GIVEN** a vault owner who edited a secret offline, and the same secret changed from another browser after the snapshot was taken +- **WHEN** the owner reconnects and the replay sends the update with the snapshot's `baseUpdatedAt` +- **THEN** the server MUST answer 409 and leave the secret unchanged +- **AND** the web app MUST show both versions and apply the owner's choice + +#### Scenario: Clients without the precondition are unaffected + +- **GIVEN** an online client that sends `PUT /api/v1/secrets/{id}` without `baseUpdatedAt` +- **WHEN** the request is processed +- **THEN** the server MUST update the secret as it did before this change + +### Requirement: Failed entries are kept, never dropped silently + +A replay answered with 403 or 404 MUST move the entry to a failed list where the user can copy their values (decrypted in the browser) or discard the entry. A 423 or a network error MUST keep the entry queued. An entry whose owner row saved but whose recipient sync failed MUST retry only the sync step. + +#### Scenario: Write grade removed while offline + +- **GIVEN** a team-folder member who edited a folder secret offline, and whose grade was lowered to read meanwhile +- **WHEN** the replay's sync request is refused with 403 +- **THEN** the entry MUST appear under "Changes that could not sync" with copy and discard actions + +### Requirement: Pending changes block logout and rotation + +The web app MUST warn before logout while entries are pending, MUST refuse to start a suite rotation while entries are pending, and after a rotation made on another device MUST ask once for the previous master password to reopen the entries with the cached old suite envelope, or let the user discard them. + +#### Scenario: Rotation waits for the queue + +- **GIVEN** a vault owner with two pending offline changes +- **WHEN** they start a compromise recovery rotation +- **THEN** the web app MUST refuse and ask them to sync or discard the changes first + +### Requirement: Administrators control offline edits + +The system MUST provide an admin setting `offline_edits_enabled`, default false, shown next to `offline_cache_enabled` and carried in the offline manifest. When offline caching is disabled, the next online unlock MUST replay the queue before purging the cache and the queue. + +#### Scenario: Administrator enables offline edits + +- **GIVEN** an administrator on the offline cache section of the Keepiq admin settings +- **WHEN** they turn on offline edits and a user next syncs online +- **THEN** the offline manifest MUST report `offlineEditsEnabled` true and the user's offline view MUST allow edits diff --git a/openspec/changes/clients-offline-edits/specs/offline-readonly-cache/spec.md b/openspec/changes/clients-offline-edits/specs/offline-readonly-cache/spec.md new file mode 100644 index 000000000..7e306082f --- /dev/null +++ b/openspec/changes/clients-offline-edits/specs/offline-readonly-cache/spec.md @@ -0,0 +1,7 @@ +## REMOVED Requirements + +### Requirement: Offline mode is strictly read-only + +**Reason**: Superseded by the `offline-edit-queue` capability. With offline edits disabled (the default) the offline view stays read-only exactly as this requirement said; with them enabled, secret edits are queued and replayed with a fresh fan-out. + +**Migration**: The read-only behaviour for sharing, link shares, sends, team-folder membership, folders and attachments moves to the requirement "Sharing and membership actions stay online-only" in `offline-edit-queue`; the read-only behaviour for secret edits when the setting is off moves to "Offline changes go into a sealed local queue". diff --git a/openspec/changes/clients-offline-edits/tasks.md b/openspec/changes/clients-offline-edits/tasks.md new file mode 100644 index 000000000..4d49da85e --- /dev/null +++ b/openspec/changes/clients-offline-edits/tasks.md @@ -0,0 +1,38 @@ +# Tasks: offline edit queue + +## 1. Server + +- [ ] 1.1 Accept an optional `baseUpdatedAt` on `SecretController::update()` and `destroy()`; when it differs from the stored `updatedAt`, change nothing and answer 409 with the current row. Verify: PHPUnit `SecretControllerTest` covers a match, a mismatch, and an absent value behaving as today. +- [ ] 1.2 Add the `offline_edits_enabled` app config (default false) to `AdminSettingsService` and to the offline manifest response. Verify: PHPUnit asserts the default, a set value, and the manifest field. + +## 2. Queue at rest + +- [ ] 2.1 Add a queue store to the offline IndexedDB database (new schema version) with entries sealed to the owner's certificate through the hybrid envelope (D1). Verify: vitest asserts no stored entry contains a plain value, name or URL, and that a round trip opens with the private key. +- [ ] 2.2 Add coalescing (D4): update chains, create then update, create then delete. Verify: vitest for each chain. + +## 3. Offline editing + +- [ ] 3.1 When offline and `offline_edits_enabled` is true, enable create, edit, move and delete in `SecretList.vue` and the secret dialogs, write to the queue, and show queued changes on the cached view marked "Not synced yet". Keep share, link share, send, team-folder, folder and attachment actions disabled with an explanation. Verify: vitest for the enabled and disabled action sets. +- [ ] 3.2 Show "N changes waiting to sync" in the stale-data banner and a warning before logout while entries are pending. Verify: vitest renders both states. + +## 4. Replay + +- [ ] 4.1 Replay the queue oldest first when online and unlocked, through the existing store actions; for a secret with recipients fetch the write context at replay time and run the normal sync fan-out. Verify: vitest with a mocked API asserts the certificates used are the ones returned at replay, not any cached value. +- [ ] 4.2 Handle outcomes (D5): 409 opens the conflict dialog, 403 and 404 move the entry to the failed list, 423 and network errors keep it queued, and a failed recipient sync retries only the sync step. Verify: vitest for each outcome. +- [ ] 4.3 Add the conflict dialog in `src/dialogs/` (keep mine, keep the server's) and the failed-changes list with copy and discard. Verify: vitest for both choices and for copy. +- [ ] 4.4 Block a suite rotation while entries are pending, and after a rotation elsewhere ask once for the previous master password to reopen entries under the cached old envelope. Verify: vitest for the block and the reopen path. + +## 5. Administrator and end-to-end + +- [ ] 5.1 Add the offline edits switch to `OfflineCacheSection.vue`. Verify: vitest for the switch and its save call. +- [ ] 5.2 Add a Playwright flow: a vault owner unlocks online, goes offline, edits a secret shared with a second user, goes online, and the second user sees the new value. Verify: the Playwright spec passes in the E2E job. +- [ ] 5.3 Add a Playwright flow for a conflict: the same secret changes online from a second browser while the first is offline; the first sees the conflict dialog on reconnect. Verify: the Playwright spec passes in the E2E job. + +## Acceptance criteria + +- With offline edits enabled, a user can create, edit, move and delete secrets offline and sees the changes at once, marked as not synced. +- The queue at rest holds no plain value, name or URL. +- Replay uses the online code paths, and recipient ciphertext is made only at replay time with certificates fetched at replay time. +- A secret changed on the server since the snapshot is never overwritten without the user's choice. +- Sharing, link shares, sends, team-folder membership, folders and attachments stay unavailable offline. +- With offline edits disabled (the default), the offline view behaves exactly as the read-only cache does today. diff --git a/openspec/changes/clients-ssh-agent/.openspec.yaml b/openspec/changes/clients-ssh-agent/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/clients-ssh-agent/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/clients-ssh-agent/design.md b/openspec/changes/clients-ssh-agent/design.md new file mode 100644 index 000000000..a6f089e99 --- /dev/null +++ b/openspec/changes/clients-ssh-agent/design.md @@ -0,0 +1,87 @@ +# Design: SSH agent in the Keepiq CLI + +## Context + +Code at development `4c214a9d`: + +- `cli/main.go:33` dispatches subcommands (`login`, `list`, `show`, `get`, `copy`, `ci`, `completion`). `cli/main.go:133` `openHumanSession()` loads the stored pairing, fetches the active suite, unwraps the private key with the master password (`dcrypto.UnwrapPrivateKey`, `cli/internal/crypto/crypto.go:89`), parses it (`:121`) and checks it against the certificate (`:177`). `cli/main.go:119` `promptSecret()` reads without echo. +- `cli/internal/client/client.go:96` `ListSecrets()` fetches every secret row (ciphertext plus index fields) and `:107` `GetSecret()` one row. The `Secret` struct (`:70`) carries `TypeID`, `FolderID`, `Key` and `AdditionalFields`. There is no secret-type lookup yet. +- `cli/internal/crypto/crypto.go:139` `DecryptField()` decrypts one RSA-OAEP chunked field. +- `cli/go.mod` declares `go 1.22` and no dependencies; `cli/README.md` and `.github/workflows/cli-release.yml` call the CLI stdlib-only. `cli-release.yml` runs `go vet` and `go test` and cross-compiles six targets. +- `ssh_key` secrets store the OpenSSH private key in the `key` field and the public key in the encrypted additional fields (`src/cxf/cxf.js:355`). The seeded development key in `lib/Repair/SeedDevelopmentSecrets.php:162` is a truncated placeholder, not a usable key. +- `openspec/specs/keepiq-cli/spec.md` requires read-only v1 and in-process decryption; the session cache is a documented follow-up. + +## Goals / Non-Goals + +**Goals:** + +- `ssh`, `git` and `scp` sign with keys held in the vault, through the standard `SSH_AUTH_SOCK` interface. +- Decrypted private keys never touch the disk and live only while the agent is unlocked. +- Optional per-use confirmation and an idle lock. + +**Non-Goals:** + +- Windows. A named-pipe listener needs another dependency and its own testing; Windows users can run the agent in WSL until a follow-up change. +- Passphrase-protected OpenSSH keys. The agent skips them and names them; a later change can read a passphrase from an additional field. +- Adding keys to the vault through `ssh-add`. The CLI is read-only in v1. +- A server-side record of each signature. The CLI adds no backend route, as `keepiq-cli` requires. +- A desktop app, a GUI prompt of our own, or a mobile agent. + +## Decisions + +### D1: A subcommand of the existing CLI + +`keepiq ssh-agent [--socket ] [--confirm] [--idle ] [--folder ] [--locked]` runs in the foreground and prints `SSH_AUTH_SOCK=; export SSH_AUTH_SOCK;` for `eval`. The default socket is `$XDG_RUNTIME_DIR/keepiq/agent.sock` on Linux and `$TMPDIR/keepiq-/agent.sock` on macOS. Running it under a systemd user unit or a launchd agent is documented. + +Alternative considered: a separate `keepiq-agent` binary. Rejected: one binary already carries the pairing, the crypto and the release pipeline. + +### D2: Two vetted dependencies instead of a hand-written protocol + +The agent uses `golang.org/x/crypto/ssh` to parse OpenSSH private keys and sign, `golang.org/x/crypto/ssh/agent` to serve the protocol (`agent.ServeAgent` over a custom `agent.ExtendedAgent`), and `golang.org/x/sys/unix` for peer credentials on macOS. Both are Go project modules, pure Go, so the binary stays static. The README and the workflow comment drop the stdlib-only claim, and the test job adds `govulncheck ./...`. + +Alternative considered: implementing the agent protocol and the OpenSSH key format by hand to stay stdlib-only. Rejected: a key parser and a signing protocol are exactly the code that should come from a maintained, audited module. + +### D3: Unlock in process, two ways + +Started from a terminal, the agent prompts for the master password with the existing `promptSecret()` and calls `openHumanSession()`. Started with `--locked` (for a service manager), it holds no keys until the user runs `ssh-add -X`, which sends a password over the socket as the protocol's unlock request; the agent treats it as the master password. `ssh-add -x` locks it again. + +On unlock the agent looks up the `ssh_key` type id through `GET /api/v1/secret-types`, lists the user's secrets, keeps those of that type (and in `--folder`, if set), decrypts each `key` field, and parses it. A key that does not parse or is passphrase-protected is skipped and named on standard error. The public key is derived from the private key, so the encrypted additional fields need not be read. Each identity's comment is the secret name. + +### D4: Supported signatures + +Ed25519, ECDSA (P-256, P-384, P-521) and RSA with `rsa-sha2-256` and `rsa-sha2-512`. An RSA sign request without one of those flags (the SHA-1 `ssh-rsa` scheme) is refused. + +### D5: Socket safety + +The agent creates the socket directory with mode `0700` and the socket with `0600`, refuses to start if the directory exists with another owner or a wider mode, and refuses a connection whose peer uid differs from its own (`SO_PEERCRED` on Linux, `LOCAL_PEERCRED` on macOS). At start it disables core dumps (`RLIMIT_CORE` 0, and on Linux `PR_SET_DUMPABLE` 0). + +### D6: Confirmation and idle lock + +With `--confirm`, before each signature the agent runs the program in `SSH_ASKPASS` with `SSH_ASKPASS_PROMPT=confirm` and the key name, and signs only on exit status 0. If `SSH_ASKPASS` is unset, `--confirm` refuses to start rather than sign unconfirmed. With `--idle ` (default 60, 0 disables), the agent drops every decrypted key and the unwrapped suite key after that many minutes without a sign request, and answers as a locked agent until the next unlock. + +### D7: The vault is the only source + +Add-identity, remove-identity and remove-all requests answer with failure. `ssh-add -l` lists vault keys; `ssh-add some_key` fails with a message pointing to the vault. + +## Security and zero-knowledge + +The server's view does not change: the agent authenticates with the paired Nextcloud app password and fetches the suite envelope and secret ciphertext, exactly as `keepiq show` does. It never sends a master password, a derived key, a private key or a signature to the server (ADR-003). + +In the agent process, while unlocked: the RSA suite private key and the parsed SSH private keys. This is inherent to any SSH agent. They are never written to disk, core dumps are disabled, and the idle lock and `ssh-add -x` drop them. The master password is used once to unwrap the suite key and then released. + +Stored in plain text on disk: only the existing CLI pairing file (server URL, user, app password, mode `0600`). The socket carries sign requests from processes of the same user, which is the same trust boundary OpenSSH's own agent uses; D5 enforces it. + +## Risks / Trade-offs + +- **An unlocked agent signs for any process of the same user.** That is the SSH agent model. `--confirm` adds a per-use prompt for users who want it. +- **`ssh-add -X` sends the master password over the local socket.** The socket is owner-only and peer-checked; the alternative (a service manager that cannot prompt) would leave no way to unlock a background agent. +- **New dependencies add supply-chain surface.** Both are Go project modules, pinned in `go.sum`, and checked with `govulncheck` in CI. +- **Windows users wait.** Recorded as a non-goal with WSL as the workaround. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Go tests generate throwaway keys in the test process; no key is committed (gitleaks). The truncated seeded `ssh_key` in `SeedDevelopmentSecrets.php` stays as it is and is expected to be skipped by the agent. + +## Migration + +None: no table, no column, no `` bump. The CLI release (`cli-v*` tag) carries the new subcommand. diff --git a/openspec/changes/clients-ssh-agent/proposal.md b/openspec/changes/clients-ssh-agent/proposal.md new file mode 100644 index 000000000..8e5d2ae52 --- /dev/null +++ b/openspec/changes/clients-ssh-agent/proposal.md @@ -0,0 +1,55 @@ +--- +kind: code +--- + +# An SSH agent in the Keepiq command-line client + +## Why + +Keepiq stores SSH keys as a secret type, but nothing hands them to `ssh` or `git`. A developer has to reveal the private key, write it to a file and load it into another agent, which defeats the point of keeping it in the vault. The three competitors that rate yes all run an agent that serves vault keys directly. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| clients-13 | Sign in over SSH with keys kept in the vault through an SSH agent. | SSH keys can be stored as secrets, but nothing exposes them to an SSH agent. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. `ssh_key` exists as a seeded secret type (`lib/Repair/SeedSecretTypes.php:64`) and as a CXF mapping (`src/cxf/cxf.js:355`, private key in the `key` field, public key in the additional fields), but no code in `lib`, `src`, `cli` or `browser-extension` speaks the agent protocol. The decision places the agent in the existing Go CLI in `cli/`, which is a native binary already, so no desktop app is needed. The product records no native app (`openspec/specs/mobile-pwa/spec.md:11` and `:49`). + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/desktop/desktop_native/core/src/ssh_agent/mod.rs, named_pipe_listener_stream.rs (Windows), peercred_unix_listener_stream.rs (Unix); bitwarden/server@v2026.9.1 src/Core/Vault/Enums/CipherType.cs:11 SSHKey Note: Desktop app runs an SSH agent serving SSH key items, with per-use approval." +- 1Password: "https://developer.1password.com/docs/ssh/agent/ : SSH Agent uses keys saved in 1Password, private key 'never even leaves the 1Password app'" +- Keeper: "https://docs.keeper.io/keeperpam/privileged-access-manager/ssh-agent : 'Keeper's built-in SSH agent' serves SSH keys stored in the vault (documented under KeeperPAM)" + +## What Changes + +- A new subcommand `keepiq ssh-agent` runs an OpenSSH-compatible agent on a Unix socket on Linux and macOS. +- The agent unlocks the vault in its own process (master password at the terminal, or through `ssh-add -X` when started locked), decrypts the user's `ssh_key` secrets in memory, and answers identity-list and sign requests. +- Optional per-use confirmation through `SSH_ASKPASS` (`--confirm`), an idle lock that drops every decrypted key (`--idle`), and a folder filter (`--folder`). +- The agent refuses to add or remove keys: the vault is the only source, matching the CLI's read-only v1. +- The CLI takes its first dependencies, `golang.org/x/crypto` (SSH key parsing, signing and the agent protocol) and `golang.org/x/sys` (peer credential checks), and CI adds `govulncheck`. +- Documentation for running the agent as a systemd user service or a launchd agent. + +## Capabilities + +### New Capabilities + +- `cli-ssh-agent`: an SSH agent in the Keepiq CLI that serves vault SSH keys with in-process decryption. + +### Modified Capabilities + +None. The `keepiq-cli` requirements stay as they are; this change adds a subcommand with its own capability. + +## Impact + +- **Backend**: none. The agent reads the same routes `keepiq list` and `keepiq show` read (`/api/v1/suites`, `/api/v1/secrets`, `/api/v1/secret-types`). +- **Frontend**: none. +- **Database**: none; no migration, no `` bump. +- **Security**: decrypted SSH private keys exist only in the agent's memory while it is unlocked; the socket is owner-only; the server sees nothing it does not see for `keepiq show`. +- **Cross-app**: none. +- **Release**: `cli-release.yml` builds the new subcommand for Linux and macOS; on Windows the subcommand reports that it is not supported yet. diff --git a/openspec/changes/clients-ssh-agent/specs/cli-ssh-agent/spec.md b/openspec/changes/clients-ssh-agent/specs/cli-ssh-agent/spec.md new file mode 100644 index 000000000..f974aea45 --- /dev/null +++ b/openspec/changes/clients-ssh-agent/specs/cli-ssh-agent/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: The CLI runs an SSH agent that serves vault SSH keys + +The CLI MUST provide `keepiq ssh-agent`, which serves the OpenSSH agent protocol on a Unix socket on Linux and macOS and prints the `SSH_AUTH_SOCK` export for that socket. Once unlocked it MUST offer every `ssh_key` secret the user owns (limited to one folder when `--folder` is given) whose private key parses without a passphrase, with the secret name as the identity comment, and MUST name each skipped key on standard error. + +#### Scenario: A developer clones over SSH with a vault key + +- **GIVEN** a developer whose vault holds an `ssh_key` secret named "GitHub deploy" and who runs `eval "$(keepiq ssh-agent)"` and enters their master password +- **WHEN** they run `git clone git@github.com:example/repo.git` +- **THEN** `ssh` MUST authenticate with the "GitHub deploy" key through the agent +- **AND** no file containing that private key MUST exist on disk + +#### Scenario: A passphrase-protected key is skipped + +- **GIVEN** a vault with one plain Ed25519 key and one passphrase-protected key +- **WHEN** the agent unlocks +- **THEN** `ssh-add -l` MUST list only the Ed25519 key +- **AND** standard error MUST name the skipped key + +### Requirement: Decryption happens only in the agent process + +The agent MUST unwrap the suite private key and decrypt SSH keys inside its own process, using the paired Nextcloud app password to fetch only ciphertext, and MUST NOT send the master password, a derived key, a private key or a signature to the server. It MUST NOT write any decrypted key to disk, and MUST disable core dumps at start. + +#### Scenario: The server sees only ciphertext reads + +- **GIVEN** an agent unlocking against a Keepiq server +- **WHEN** the requests the agent makes are recorded +- **THEN** they MUST be reads of `/api/v1/suites`, `/api/v1/secret-types` and `/api/v1/secrets` +- **AND** no request body MUST contain the master password or any key material + +### Requirement: The agent unlocks from a terminal or through ssh-add + +The agent MUST prompt for the master password at start when run from a terminal. When started with `--locked` it MUST hold no keys until an `ssh-add -X` unlock request supplies the master password, and `ssh-add -x` MUST lock it again. While locked it MUST answer identity requests with an empty list and refuse every sign request. + +#### Scenario: A service-managed agent is unlocked later + +- **GIVEN** an agent started with `--locked` by a systemd user unit +- **WHEN** the developer runs `ssh-add -X` and enters their master password +- **THEN** `ssh-add -l` MUST list their vault keys + +#### Scenario: Locking drops the keys + +- **GIVEN** an unlocked agent +- **WHEN** the developer runs `ssh-add -x` +- **THEN** a following sign request MUST be refused +- **AND** `ssh-add -l` MUST report no identities + +### Requirement: Only modern signature schemes + +The agent MUST sign with Ed25519, with ECDSA on P-256, P-384 and P-521, and with RSA only when the request carries the `rsa-sha2-256` or `rsa-sha2-512` flag. An RSA sign request without one of those flags MUST be refused. + +#### Scenario: A SHA-1 RSA request is refused + +- **GIVEN** an unlocked agent holding an RSA key +- **WHEN** a client asks for an `ssh-rsa` signature without a SHA-2 flag +- **THEN** the agent MUST answer with failure and produce no signature + +### Requirement: The socket is private to the user + +The agent MUST create its socket directory with mode `0700` and the socket with mode `0600`, MUST refuse to start when the directory exists with another owner or a wider mode, and MUST close any connection whose peer uid differs from its own. + +#### Scenario: Another local user is refused + +- **GIVEN** an agent run by user `alice` +- **WHEN** a process of user `bob` connects to the socket +- **THEN** the agent MUST close the connection without answering any request + +### Requirement: Optional confirmation and idle lock + +With `--confirm` the agent MUST run the program in `SSH_ASKPASS` with `SSH_ASKPASS_PROMPT=confirm` and the key name before each signature and sign only on exit status 0; it MUST refuse to start with `--confirm` when `SSH_ASKPASS` is unset. With `--idle ` (default 60, 0 disables) it MUST drop every decrypted key and the suite key after that many minutes without a sign request. + +#### Scenario: A denied confirmation blocks the signature + +- **GIVEN** an agent started with `--confirm` +- **WHEN** `ssh` asks for a signature and the user dismisses the confirmation dialog +- **THEN** the agent MUST refuse the signature + +#### Scenario: The idle lock clears keys + +- **GIVEN** an agent started with `--idle 30` and no sign request for 30 minutes +- **WHEN** `ssh` next asks for a signature +- **THEN** the agent MUST refuse it as locked until the developer unlocks again + +### Requirement: The vault is the only key source + +The agent MUST answer add-identity, remove-identity and remove-all-identities requests with failure. + +#### Scenario: ssh-add cannot add a local key + +- **GIVEN** an unlocked agent +- **WHEN** the developer runs `ssh-add ~/.ssh/id_ed25519` +- **THEN** the agent MUST refuse the request and the key MUST NOT be listed diff --git a/openspec/changes/clients-ssh-agent/tasks.md b/openspec/changes/clients-ssh-agent/tasks.md new file mode 100644 index 000000000..32ff524a4 --- /dev/null +++ b/openspec/changes/clients-ssh-agent/tasks.md @@ -0,0 +1,31 @@ +# Tasks: SSH agent in the Keepiq CLI + +## 1. Dependencies and plumbing + +- [ ] 1.1 Add `golang.org/x/crypto` and `golang.org/x/sys` to `cli/go.mod`, add `govulncheck ./...` to the test job in `cli-release.yml`, and update the stdlib-only wording in `cli/README.md` and the workflow comment. Verify: `go vet ./...`, `go test ./...` and `govulncheck ./...` pass in the workflow. +- [ ] 1.2 Add `SecretTypes()` to `cli/internal/client/client.go` (`GET /api/v1/secret-types`) and a helper that returns the `ssh_key` type id. Verify: a Go unit test with an `httptest` server. + +## 2. Agent core + +- [ ] 2.1 Add `cli/sshagent/` with a keyring that loads the user's `ssh_key` secrets (optionally one folder), decrypts and parses each key, skips and names passphrase-protected or unparsable ones, and derives the public keys. Verify: a Go test with generated Ed25519, ECDSA and RSA keys plus one passphrase-protected key. +- [ ] 2.2 Implement `agent.ExtendedAgent`: list, sign (Ed25519, ECDSA, RSA with SHA-2 flags only), lock and unlock (master password), and failure for add and remove. Verify: a Go test drives it through `agent.NewClient` over `net.Pipe`, verifies each signature, and asserts the SHA-1 RSA refusal and the add refusal. +- [ ] 2.3 Add the idle lock (D6) that drops every decrypted key and the suite key. Verify: a Go test with an injected clock asserts a locked answer after the idle period. +- [ ] 2.4 Add `--confirm` through `SSH_ASKPASS` with `SSH_ASKPASS_PROMPT=confirm`, refusing to start without `SSH_ASKPASS`. Verify: a Go test with stub askpass scripts exiting 0 and 1. + +## 3. Socket and command + +- [ ] 3.1 Add the socket listener with directory `0700`, socket `0600`, the owner and mode check, and the peer uid check on Linux and macOS; disable core dumps at start. Verify: Go tests for a wrong directory mode and a foreign peer uid (Linux test in CI). +- [ ] 3.2 Add the `ssh-agent` subcommand (`--socket`, `--confirm`, `--idle`, `--folder`, `--locked`), the `SSH_AUTH_SOCK` output, usage and completion entries, and a "not supported on Windows yet" message behind a build tag. Verify: a Go test for flag parsing; `GOOS=windows go build` succeeds. +- [ ] 3.3 Add an integration test that starts a throwaway `sshd` on localhost with a generated key in CI, loads the key into a test vault double, and runs `ssh -o IdentityAgent=` to it. Verify: the test passes in the CLI workflow on Linux. + +## 4. Documentation + +- [ ] 4.1 Document the agent in `cli/README.md`: start, `eval`, `ssh-add -X` unlock, `--confirm`, `--idle`, a systemd user unit and a launchd plist, and the Windows status. Verify: manual review with the writing skill, and a manual `git clone` over SSH on macOS and Linux using the agent. + +## Acceptance criteria + +- `keepiq ssh-agent` serves the user's vault SSH keys to `ssh` and `git` on Linux and macOS through `SSH_AUTH_SOCK`. +- Keys are decrypted only in the agent process; nothing decrypted is written to disk and the server receives no key material. +- The socket is reachable only by the same user; a foreign peer is refused. +- `--confirm` asks before each signature and fails closed; `--idle` drops all decrypted keys after the idle period. +- `ssh-add` cannot add or remove keys through the agent. From c85320b1ff723b83250082fa16ac2b7aa25fc4e5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:23:03 +0200 Subject: [PATCH 2/3] docs(openspec): specify organisation account recovery, new device approval, federated recipients, team-folder managers, use-only and expiring shares --- .../crypto-new-device-approval/.openspec.yaml | 2 + .../crypto-new-device-approval/design.md | 82 ++++++++++++ .../crypto-new-device-approval/proposal.md | 54 ++++++++ .../specs/new-device-approval/spec.md | 96 ++++++++++++++ .../crypto-new-device-approval/tasks.md | 37 ++++++ .../.openspec.yaml | 2 + .../design.md | 113 ++++++++++++++++ .../proposal.md | 55 ++++++++ .../organisation-account-recovery/spec.md | 125 ++++++++++++++++++ .../tasks.md | 44 ++++++ .../.openspec.yaml | 2 + .../sharing-federated-recipients/design.md | 89 +++++++++++++ .../sharing-federated-recipients/proposal.md | 53 ++++++++ .../specs/federated-sharing/spec.md | 92 +++++++++++++ .../sharing-federated-recipients/tasks.md | 39 ++++++ .../.openspec.yaml | 2 + .../design.md | 77 +++++++++++ .../proposal.md | 54 ++++++++ .../specs/folder-permission-grades/spec.md | 86 ++++++++++++ .../sharing-team-folder-manager-role/tasks.md | 30 +++++ .../.openspec.yaml | 2 + .../design.md | 91 +++++++++++++ .../proposal.md | 64 +++++++++ .../specs/expiring-shares/spec.md | 68 ++++++++++ .../specs/use-only-shares/spec.md | 83 ++++++++++++ .../tasks.md | 44 ++++++ 26 files changed, 1486 insertions(+) create mode 100644 openspec/changes/crypto-new-device-approval/.openspec.yaml create mode 100644 openspec/changes/crypto-new-device-approval/design.md create mode 100644 openspec/changes/crypto-new-device-approval/proposal.md create mode 100644 openspec/changes/crypto-new-device-approval/specs/new-device-approval/spec.md create mode 100644 openspec/changes/crypto-new-device-approval/tasks.md create mode 100644 openspec/changes/crypto-organisation-account-recovery/.openspec.yaml create mode 100644 openspec/changes/crypto-organisation-account-recovery/design.md create mode 100644 openspec/changes/crypto-organisation-account-recovery/proposal.md create mode 100644 openspec/changes/crypto-organisation-account-recovery/specs/organisation-account-recovery/spec.md create mode 100644 openspec/changes/crypto-organisation-account-recovery/tasks.md create mode 100644 openspec/changes/sharing-federated-recipients/.openspec.yaml create mode 100644 openspec/changes/sharing-federated-recipients/design.md create mode 100644 openspec/changes/sharing-federated-recipients/proposal.md create mode 100644 openspec/changes/sharing-federated-recipients/specs/federated-sharing/spec.md create mode 100644 openspec/changes/sharing-federated-recipients/tasks.md create mode 100644 openspec/changes/sharing-team-folder-manager-role/.openspec.yaml create mode 100644 openspec/changes/sharing-team-folder-manager-role/design.md create mode 100644 openspec/changes/sharing-team-folder-manager-role/proposal.md create mode 100644 openspec/changes/sharing-team-folder-manager-role/specs/folder-permission-grades/spec.md create mode 100644 openspec/changes/sharing-team-folder-manager-role/tasks.md create mode 100644 openspec/changes/sharing-use-only-and-expiring-shares/.openspec.yaml create mode 100644 openspec/changes/sharing-use-only-and-expiring-shares/design.md create mode 100644 openspec/changes/sharing-use-only-and-expiring-shares/proposal.md create mode 100644 openspec/changes/sharing-use-only-and-expiring-shares/specs/expiring-shares/spec.md create mode 100644 openspec/changes/sharing-use-only-and-expiring-shares/specs/use-only-shares/spec.md create mode 100644 openspec/changes/sharing-use-only-and-expiring-shares/tasks.md diff --git a/openspec/changes/crypto-new-device-approval/.openspec.yaml b/openspec/changes/crypto-new-device-approval/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/crypto-new-device-approval/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/crypto-new-device-approval/design.md b/openspec/changes/crypto-new-device-approval/design.md new file mode 100644 index 000000000..8783fbc53 --- /dev/null +++ b/openspec/changes/crypto-new-device-approval/design.md @@ -0,0 +1,82 @@ +# Design: new device approval + +## Context + +Code at development `4c214a9d`: + +- Web unlock: `src/views/LockScreen.vue` on /lock. `src/store/modules/session.js:134` `unlockWithRawKey(rawUnlockKey)` already unlocks from a raw unlock key: it fetches the active suite, runs `decryptPrivateKeyWithRawKey`, imports the non-extractable `CryptoKey`, and imports the raw key as the AES metadata key used by the offline cache. The passkey unlock uses it (`src/store/modules/passkey.js:236` to `:238`). +- The unlocked session holds only non-extractable keys, so no page can export the raw unlock key without the master password or a PRF passkey. The passkey enrolment re-asks for the master password for the same reason (`src/store/modules/passkey.js:94`). +- `deriveUnlockKeyRaw(password, salt)` (`src/crypto/aes.js:104`) rebuilds the raw unlock key from the master password and the envelope salt. +- HPKE base mode in `src/crypto/hpke.js` (`generateRecipientKeyPair` `:200`, `seal` `:315`, `open` `:342`). +- Extension unlock: `browser-extension/src/lib/vault.js:49` takes a master password only; the change `clients-extension-unlock-lock-and-accounts` adds a raw-key unlock message to the worker. +- Vault-key proofs: `lib/Service/VaultKeyProofService.php:60` to `:74`, the `#[VaultKeyProofRequired]` attribute, and `tests/Unit/Controller/VaultKeyProofAttributesTest.php`. +- Notifications: `lib/Notification/KeepiqNotifier.php` subjects, routed through `NotificationService::SUBJECT_SETTING_MAP`. +- Rate limiting: Nextcloud's `#[UserRateLimit]` attribute (`OCP\AppFramework\Http\Attribute\UserRateLimit`); the app already uses `#[AnonRateLimit]` (`lib/Controller/ApplicationSecretRequestsController.php:88`). + +## Goals / Non-Goals + +**Goals:** + +- A user unlocks a new browser or the extension by approving it from a device where Keepiq is unlocked, without typing the master password on the new device. +- The server relays only ciphertext it cannot open. +- A person who holds only the user's Nextcloud session cannot get the vault opened without the user noticing and approving. +- An administrator-held path for users enrolled in organisation account recovery, with the server still keyless. + +**Non-Goals:** + +- The Go CLI as a requesting device. It keeps the master password; HPKE in Go is a follow-up. +- The extension as an approving device. +- Remembering the new device. The approval unlocks one session; the next unlock needs the master password, a passkey, or another approval. +- Signing in to Nextcloud itself. Nextcloud owns login; this change is about the vault. + +## Decisions + +### D1: The new device brings a one-time key + +The requesting client generates an X25519 key pair with `generateRecipientKeyPair()`, keeps the private key in memory for the life of the request, and calls `POST /api/v1/device-approvals` with the public key, its client kind (`web` or `extension`) and a device label (browser and OS from the user agent). The server stores the request with the caller's IP address and user agent, a 15 minute expiry, and the hash of a random request secret it returns once. Only the creating client knows that secret, and pickup requires it. + +### D2: One verification phrase on both screens + +Both devices show a phrase derived from the SHA-256 of the one-time public key: five words from a fixed word list, in the same helper the account recovery change uses. If the server, or anyone in between, swapped the key, the phrases differ and the user denies. + +### D3: Approval needs the master password or a passkey + +The unlocked web app sees pending requests through `GET /api/v1/device-approvals/pending` (polled while unlocked, and opened from the Nextcloud notification). The dialog shows the device label, the client kind, the IP address, the time and the phrase, with the warning "Only approve a device you are using right now." + +To approve, the user confirms their master password (or a PRF passkey). The browser derives the raw unlock key with `deriveUnlockKeyRaw` (or unwraps it with the passkey), checks it against the suite envelope, seals it with HPKE to the request public key (`info` `keepiq-device-approval-v1`, `aad` the request id), and calls `POST /api/v1/device-approvals/{id}/approve` with the sealed key. The route carries `#[VaultKeyProofRequired(binds: ['id', 'sealedUnlockKey'], subject: 'active', purpose: 'approve-device')]`, so an unlocked tab running injected script cannot approve on its own. + +Alternative considered: sealing the RSA private key instead of the raw unlock key. Rejected: the web session also needs the raw unlock key as its offline metadata key, and the unlock path from a raw key already exists and is tested. + +### D4: Pickup is one-time + +The requesting client polls `GET /api/v1/device-approvals/{id}` with its request secret every three seconds until approval, denial or expiry. On approval the response carries the sealed key once; the server then clears it and marks the request `consumed`. The client opens it with its one-time private key, unlocks through `unlockWithRawKey` (web) or the worker's raw-key unlock (extension), and drops the one-time key. + +### D5: Deny, expire, limit, notify + +`POST /api/v1/device-approvals/{id}/deny` ends a request; the dialog then offers a link to the Nextcloud security settings to end other sessions. A background job marks requests past their expiry as `expired`. `POST /api/v1/device-approvals` is limited with `#[UserRateLimit(limit: 3, period: 3600)]`. Each request raises a Nextcloud notification (`device_approval_requested`). Creation, approval, denial, expiry and pickup are audited with identifiers only. App config `device_approval_enabled` (default true) lets an administrator turn the feature off; when off, creation is refused and the option is hidden. + +### D6: The administrator-held path is organisation account recovery + +An administrator cannot approve a device on their own: the server has no key to give. For a user enrolled in organisation account recovery, the new device offers "Ask your organisation instead". That files a recovery request (change `crypto-organisation-account-recovery`) carrying the device's one-time key and the purpose `device`. Officers approve it as any recovery request, with the verification phrase and the threshold. The handoff seals the private key to the device's key. For purpose `device` the user's browser unlocks the session with the recovered private key and is not asked to set a new master password; the offline cache stays off for that session because no raw unlock key is present. For users who are not enrolled, the option is not shown. + +## Security and zero-knowledge + +Stored per request: the user id, the client kind, the device label, the IP address and user agent, the one-time public key, the request secret hash, the status, the times, and, between approval and pickup, the HPKE-sealed unlock key. The server cannot open the sealed key: only the requesting device holds the one-time private key. It never sees the master password, the raw unlock key or the private key (ADR-003). + +A stolen Nextcloud session can create a request, but opening the vault still needs the real user to approve it on an unlocked device with their master password or passkey, after seeing the device details and the phrase. The notification and the audit trail make every attempt visible, and the rate limit caps prompt spam. + +The raw unlock key exists briefly in the approving page and in the requesting client's memory, never in storage. + +## Risks / Trade-offs + +- **A user approves without reading.** The dialog leads with the device details and the phrase and needs a password or passkey; the administrator can turn the feature off. +- **The new device must stay open** until approval. A closed tab loses the one-time key; the request then expires unused. +- **Approvals only from the web app.** Users whose only unlocked client is the extension must use the master password on the new device. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. The Playwright flow uses two browser contexts for one seeded user. + +## Migration + +A new table `keepiq_device_approvals`: id, user_id, client_kind, device_label, requester_ip, requester_agent, request_public_key, request_secret_hash, status (`pending`, `approved`, `denied`, `expired`, `consumed`), created_at, expires_at, decided_at, sealed_unlock_key (TEXT, nullable), with an index on user and status. The `` in `appinfo/info.xml` must bump. diff --git a/openspec/changes/crypto-new-device-approval/proposal.md b/openspec/changes/crypto-new-device-approval/proposal.md new file mode 100644 index 000000000..b242a1599 --- /dev/null +++ b/openspec/changes/crypto-new-device-approval/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Approve a new device from a device that is already unlocked + +## Why + +Every device unlocks the Keepiq vault by typing the master password. A user setting up the browser extension, or opening Keepiq on a new laptop, has to type a long password on a keyboard they may not trust yet, and there is no way to let a device they already use vouch for the new one. Competitors let a signed-in device approve the new one, and some let an administrator do it. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| crypto-24 | Approve a sign-in on a new device from a device where you are already signed in, or have an administrator approve it. | Sign-in is Nextcloud's; a new device unlocks the vault with the user's passphrase, and there is no approve-from-another-device or admin approval flow. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. A search for device approval, auth requests or login requests in `lib/`, `src/` and `appinfo/routes.php` finds nothing; the web app unlocks at /lock with the master password or a passkey (`src/store/modules/passkey.js:193`), and the extension with the master password (`browser-extension/src/popup/popup.js:182`). + +### Demand + +- changelog: https://github.com/bitwarden/server/releases/tag/v2026.4.0 + +### Competitors rated yes + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Api/Auth/Controllers/AuthRequestsController.cs:78 POST auth-requests, :91 admin-request, :104 PUT {id} (approve); bitwarden/clients@web-v2026.9.0 libs/angular/src/auth/login-approval/login-approval-dialog.component.ts; bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.ts ..." +- Passbolt: "passbolt/passbolt_api@v5.16.0 plugins/PassboltEe/AccountRecovery/config/routes.php:52 POST /account-recovery/requests, :61 admin review, :68 responses; plugins/PassboltCe/Mobile/config/routes.php:27 transfer from a signed-in browser ... A new mobile or desktop device is set up from a browser where the user is signed in, and a lost browser can be restored through an account recovery request that an admin approves (Pro)." +- Nextcloud Passwords: "marius-wieschollek/passwords@2026.9.0 src/lib/Controller/Link/ConnectController.php:136 request() from the new client, :221 confirm() by the signed-in web session, :260 apply(codes); src/vue/Dialog/ConnectClient.vue with ConnectConfirm.vue; :242 new client notification Note: PassLink lets a new extension or app sign in by being approved from a browser where you are already signed in; no admin approval option." + +## What Changes + +- On the lock screen at /lock, and in the locked extension popup, a user can choose "Approve from another device". The new device makes a one-time key pair, sends the public key, and shows a verification phrase. +- The user's unlocked web app shows the request with the device details and the same phrase. After comparing the phrase and confirming their master password (or passkey), the user approves. Their browser seals the vault unlock key to the new device's one-time key; the server only relays the sealed result. +- The new device opens the sealed key and unlocks for this session. The server deletes the sealed result on first pickup. +- Requests expire after 15 minutes, are rate-limited, are announced by a Nextcloud notification, and are audited. The user can deny a request and is then pointed to end their other Nextcloud sessions. +- An administrator-held path exists only for users enrolled in organisation account recovery (change `crypto-organisation-account-recovery`): the device request becomes a recovery request for that device, approved by recovery officers, and the server stays keyless. +- An administrator can turn device approval off. + +## Capabilities + +### New Capabilities + +- `new-device-approval`: device approval requests, the verification phrase, approval with a sealed unlock key, pickup, denial, limits, and the officer path for enrolled users. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: a `DeviceApprovalController` and service, a new vault-key proof purpose `approve-device`, a background job that expires requests, and a notification subject. +- **Frontend**: the lock screen and an approval dialog in the web app; the locked view of the extension popup. +- **Database**: one new table; a migration and a `` bump. +- **Security**: the server relays only an HPKE-sealed unlock key it cannot open; approval needs the master password or a passkey on the approving device; the phrase defends against a swapped key. +- **Cross-app**: none. The officer path depends on `crypto-organisation-account-recovery` being built first. diff --git a/openspec/changes/crypto-new-device-approval/specs/new-device-approval/spec.md b/openspec/changes/crypto-new-device-approval/specs/new-device-approval/spec.md new file mode 100644 index 000000000..da4f1f92b --- /dev/null +++ b/openspec/changes/crypto-new-device-approval/specs/new-device-approval/spec.md @@ -0,0 +1,96 @@ +## ADDED Requirements + +### Requirement: A new device requests approval with a one-time key + +A signed-in client whose vault is locked (the web app at /lock, or the paired browser extension) MUST be able to request approval by generating a one-time X25519 key pair, keeping the private key in memory only, and calling `POST /api/v1/device-approvals` with the public key, its client kind and a device label. The system MUST store the request with the caller's IP address and user agent and a 15 minute expiry, MUST return a request secret once, MUST raise a Nextcloud notification to the user, and MUST refuse more than three requests per user per hour. + +#### Scenario: A user asks from a new laptop + +- **GIVEN** a user signed in to Nextcloud on a new laptop whose Keepiq vault is locked +- **WHEN** they choose "Approve from another device" on the lock screen at /lock +- **THEN** a pending request MUST be stored with a 15 minute expiry and the laptop MUST show a verification phrase +- **AND** the user MUST receive a Nextcloud notification about the request + +#### Scenario: Request spam is limited + +- **GIVEN** a user who created three requests in the last hour +- **WHEN** a fourth `POST /api/v1/device-approvals` arrives +- **THEN** the system MUST refuse it with a rate-limit response + +### Requirement: Both devices show the same verification phrase + +The requesting device and the approval dialog MUST show a phrase derived from the SHA-256 of the one-time public key. The approval dialog MUST also show the device label, client kind, IP address and request time. + +#### Scenario: A swapped key is visible + +- **GIVEN** a request whose public key was replaced on its way to the approving device +- **WHEN** the user compares the two screens +- **THEN** the phrases MUST differ + +### Requirement: Approval seals the unlock key and needs proof of the master password + +An unlocked user MUST be able to approve a pending request of their own from the web app only after confirming their master password or a PRF passkey. The approving browser MUST seal the raw vault unlock key to the request public key with HPKE, and MUST send only the sealed key. `POST /api/v1/device-approvals/{id}/approve` MUST require a vault-key proof from the user's active suite for purpose `approve-device` bound to the request id and the sealed key. The system MUST refuse approval of an expired, decided or foreign request. + +#### Scenario: The user approves their own new laptop + +- **GIVEN** a user with Keepiq unlocked on their desktop and a pending request from their new laptop with matching phrases +- **WHEN** they approve in the dialog and confirm their master password +- **THEN** the request MUST become `approved` and hold a sealed unlock key +- **AND** the approve request MUST NOT contain the master password or the raw unlock key + +#### Scenario: An unlocked tab cannot approve by itself + +- **GIVEN** an unlocked web app session and a pending request +- **WHEN** a script calls the approve route with a sealed key but without a vault-key proof +- **THEN** the system MUST refuse the approval and the request MUST stay pending + +### Requirement: Pickup is one-time and unlocks one session + +The requesting client MUST fetch the result with its request secret. The system MUST return the sealed key at most once and then clear it and mark the request `consumed`. The client MUST open the sealed key with its one-time private key, unlock its session, and discard the one-time key. The unlock MUST NOT be remembered beyond that session. + +#### Scenario: The new laptop unlocks + +- **GIVEN** an approved request +- **WHEN** the new laptop polls `GET /api/v1/device-approvals/{id}` with its request secret +- **THEN** it MUST receive the sealed key and unlock its vault view +- **AND** a second fetch MUST return no key + +#### Scenario: The extension unlocks through approval + +- **GIVEN** a paired but locked browser extension +- **WHEN** the user requests approval from the popup and approves it in their unlocked web app +- **THEN** the extension MUST unlock and list matching logins for the current site + +### Requirement: Deny, expiry, audit and administrator switch + +The user MUST be able to deny a pending request, after which the dialog MUST point to the Nextcloud security settings to end other sessions. The system MUST expire pending requests after 15 minutes, MUST audit creation, approval, denial, expiry and pickup with identifiers only, and MUST let an administrator turn device approval off with `device_approval_enabled`. + +#### Scenario: An unknown device is denied + +- **GIVEN** a pending request from a device the user does not recognise +- **WHEN** the user denies it +- **THEN** the request MUST become `denied` and no key MUST ever be released for it +- **AND** the dialog MUST offer the link to end other sessions + +#### Scenario: Feature switched off + +- **GIVEN** an administrator who turned device approval off +- **WHEN** a user opens the lock screen at /lock +- **THEN** the "Approve from another device" option MUST NOT be shown and the create route MUST refuse + +### Requirement: The administrator path goes through organisation account recovery + +For a user enrolled in organisation account recovery, the requesting device MUST offer to file a recovery request with purpose `device` carrying its one-time key, approved by recovery officers under that capability's threshold and verification phrase. For purpose `device`, the device MUST unlock the session with the recovered private key without asking for a new master password. For a user who is not enrolled, no administrator path MUST be offered. + +#### Scenario: Officers approve a device for an enrolled user + +- **GIVEN** an enrolled user on a new laptop with no other unlocked device +- **WHEN** they choose "Ask your organisation instead" and the required officers approve after comparing the phrase +- **THEN** the laptop MUST unlock the vault for this session +- **AND** the server MUST never have held a key that opens the handoff + +#### Scenario: Not enrolled, no administrator path + +- **GIVEN** a user who is not enrolled in organisation account recovery +- **WHEN** they open the device approval screen +- **THEN** the option to ask the organisation MUST NOT be shown diff --git a/openspec/changes/crypto-new-device-approval/tasks.md b/openspec/changes/crypto-new-device-approval/tasks.md new file mode 100644 index 000000000..043496a2d --- /dev/null +++ b/openspec/changes/crypto-new-device-approval/tasks.md @@ -0,0 +1,37 @@ +# Tasks: new device approval + +## 1. Server + +- [ ] 1.1 Add the `keepiq_device_approvals` table, entity and mapper with a migration and a `` bump. Verify: a PHPUnit migration test asserts the table and index. +- [ ] 1.2 Add `POST /api/v1/device-approvals` (own user only, `#[UserRateLimit(limit: 3, period: 3600)]`, refused when `device_approval_enabled` is false) returning the request id and a one-time request secret, and raising the `device_approval_requested` notification. Verify: PHPUnit for creation, the disabled setting and the notification; a controller attribute test for the rate limit. +- [ ] 1.3 Add `GET /api/v1/device-approvals/pending` and `POST /api/v1/device-approvals/{id}/deny`, both scoped to the request's own user. Verify: PHPUnit refuses another user's request with the same answer as an unknown id. +- [ ] 1.4 Add `POST /api/v1/device-approvals/{id}/approve` with `#[VaultKeyProofRequired(binds: ['id', 'sealedUnlockKey'], subject: 'active', purpose: 'approve-device')]`, and add it to `VaultKeyProofAttributesTest`. Verify: PHPUnit refuses an approval without a proof, for an expired request, and for another user. +- [ ] 1.5 Add `GET /api/v1/device-approvals/{id}` that needs the request secret, returns the sealed key once and marks the request `consumed`. Verify: PHPUnit asserts a second pickup returns no key and a wrong secret is refused. +- [ ] 1.6 Add a background job that expires pending requests and audit events for every transition (identifiers only). Verify: PHPUnit for the job and for audit metadata holding no key. + +## 2. Web app + +- [ ] 2.1 Add the verification phrase helper in `src/crypto/` (shared with account recovery) and the "Approve from another device" path on the lock screen at /lock: one-time key, request, phrase, polling, unlock through `unlockWithRawKey`. Verify: vitest for the phrase and for the unlock after a mocked approval. +- [ ] 2.2 Add the approval dialog in `src/dialogs/` for an unlocked user: device details, phrase, warning, master password or passkey confirmation, HPKE seal, approve and deny. Verify: vitest asserts the approve request holds only the sealed key and the proof headers. +- [ ] 2.3 Add the `device_approval_enabled` switch to the admin settings. Verify: vitest for the switch; PHPUnit for the default. + +## 3. Extension + +- [ ] 3.1 Add "Approve from another device" to the locked popup, sealing and unlocking through the worker's raw-key unlock. Verify: vitest with a mocked API unlocks the worker from an approved request. + +## 4. Officer path + +- [ ] 4.1 For users enrolled in organisation account recovery, add "Ask your organisation instead", filing a recovery request with purpose `device` and the device's one-time key, and unlocking the session from the recovered private key without a password reset. Verify: vitest for the purpose flag and the unlock; depends on `crypto-organisation-account-recovery`. + +## 5. End to end + +- [ ] 5.1 Add a Playwright flow with two browser contexts for one user: the second context requests approval, the first approves after the phrases match, and the second context unlocks and lists the vault. Verify: the Playwright spec passes in the E2E job. + +## Acceptance criteria + +- A user can unlock a new browser or the extension by approving it from their unlocked web app, without typing the master password on the new device. +- The server stores and relays only a sealed unlock key it cannot open, and deletes it at first pickup. +- An approval needs the master password or a passkey on the approving device, and a valid vault-key proof. +- Both devices show the same verification phrase; a swapped key shows different phrases. +- Requests expire after 15 minutes, are limited to three per hour, raise a notification and are audited. +- The administrator path exists only for users enrolled in organisation account recovery. diff --git a/openspec/changes/crypto-organisation-account-recovery/.openspec.yaml b/openspec/changes/crypto-organisation-account-recovery/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/crypto-organisation-account-recovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/crypto-organisation-account-recovery/design.md b/openspec/changes/crypto-organisation-account-recovery/design.md new file mode 100644 index 000000000..0e0234c73 --- /dev/null +++ b/openspec/changes/crypto-organisation-account-recovery/design.md @@ -0,0 +1,113 @@ +# Design: organisation account recovery + +## Context + +Code at development `4c214a9d`: + +- Key hierarchy (ADR-003, `src/crypto/aes.js`): master password plus the suite envelope's salt gives the raw unlock key (`deriveUnlockKeyRaw`, `:104`), which decrypts the AES-wrapped RSA-4096 suite private key (`decryptPrivateKey` `:79`, `decryptPrivateKeyWithRawKey` `:132`). The browser holds the private key as a non-extractable `CryptoKey`. +- Emergency access escrows the grantor's private key PEM with a hybrid envelope to a grantee certificate: `buildRecoveryEnvelope(privateKeyPem, granteeCertificatePem)` (`src/crypto/emergencyEnvelope.js:52`, AES-256-GCM content key RSA-OAEP wrapped) and `openRecoveryEnvelope()` (`:93`). The server stores only the envelope (`lib/Service/EmergencyAccessService.php`, `designate` `:161`, `fetchEnvelope` `:370` releasing it only to the named grantee in the `approved` state). On rotation the rotating browser re-envelopes contacts (`lib/Controller/MigrationController.php:514`, route `appinfo/routes.php:68`), and `lib/Listener/EmergencyAccessSuiteRotationListener.php:44` sweeps the rest. +- Replacing a suite's private-key wrapping: `PUT /api/v1/suites/{id}/private-key` (`lib/Controller/EncryptionSuiteController.php:254`), guarded by `#[VaultKeyProofRequired(binds: ['encryptedPrivateKey'], subject: 'routeParam:id', ...)]` at `:249`: a signature by that suite's own private key. +- Vault-key proofs (`docs/ARCHITECTURE.md` section 4.2, `lib/Service/VaultKeyProofService.php:60` to `:74` for the purposes) and the reflection test `tests/Unit/Controller/VaultKeyProofAttributesTest.php`. +- Administrator force-revocation (ADR-005): `EncryptionSuiteController::forceRevoke`, route `appinfo/routes.php:40`, UI `src/components/settings/AdminSuiteSection.vue:21` and `:180`. Revocation fires `EncryptionSuiteRevokedEvent` (`lib/Listener/EncryptionSuiteRevokedListener.php:45`). +- CA issuance: `lib/Service/CertificateIssuanceService.php:114` `signPublicKey()` and `:211` `signCsr()`. +- HPKE base mode (X25519, HKDF-SHA256, AES-256-GCM) in `src/crypto/hpke.js` (`generateRecipientKeyPair` `:200`, `seal` `:315`, `open` `:342`). +- Lock screen: `src/views/LockScreen.vue`, route `/lock` (`src/router/guards.js`). + +## Goals / Non-Goals + +**Goals:** + +- A user who forgot their master password regains their own vault, with the same key pair and every secret readable. +- The server never holds the recovery private key, a user's private key, or the master password in usable form. +- No single person can recover an account alone when the threshold is two or more. +- A user knows whether they are enrolled, and knows when a recovery happened. + +**Non-Goals:** + +- Recovery for application-owned suites. Applications hold their own keys; ADR-005 force-revocation stays their route. +- Splitting the recovery private key into threshold shares (Shamir). See D2. +- Administrators reading a user's secrets. Recovery gives the key to the user's own browser only. +- A recovery key held on paper or offline hardware outside Keepiq. + +## Decisions + +### D1: The escrow mirrors emergency access + +Enrolment is `buildRecoveryEnvelope(privateKeyPem, recoveryCertificatePem)`: the same hybrid envelope, the same client-side build, the same "server stores only the envelope" rule, with the organisation recovery certificate as recipient. The user's browser needs the private key PEM, so enrolment asks for the master password once (at the next unlock under the required policy, where the password is already in hand). + +Wrapping the private key rather than the raw unlock key means a routine master password change keeps the enrolment valid; a key rotation replaces it (D7). + +### D2: Officers hold the recovery key one copy each, and the server enforces a threshold + +An administrator names the officers and a threshold `k` (1 to the number of officers). An officer then generates the recovery key pair in their browser (`generateKeyPair`), gets the certificate issued through `signPublicKey()`, wraps the private key PEM to every officer's current suite certificate with the same hybrid envelope, posts the wrapped copies, and discards the key. The server stores the certificate and one wrapped copy per officer. + +A recovery needs `k` distinct officer approvals, each a vault-key proof. Only then does the server release the user's enrolment envelope, and only to an officer who approved. + +Alternative considered: Shamir splitting of the recovery private key so that `k` officers must each contribute a share. Rejected for this change: the shares must be combined in one browser, which then holds the full key anyway, and every officer change forces a new split and a full re-enrolment. The honest limit of D2 is stated in the security section. + +### D3: The request carries a one-time key and a verification phrase + +On the lock screen a user who is enrolled can choose "Forgot your master password?". Their browser generates an X25519 key pair (`src/crypto/hpke.js`), keeps the private key in IndexedDB as a non-extractable key bound to the request id, and posts the public key. The request expires after 72 hours. Both the user's screen and each officer's approval dialog show a verification phrase derived from the SHA-256 of that public key. The officer compares the phrase with the user over a channel they trust (in person or by phone) before approving. A phrase mismatch means the key was swapped on the way, and the officer declines. + +### D4: Handoff through one approving officer's browser + +When the threshold is met, the next approving officer who opens the request fetches the enrolment envelope and their own wrapped copy of the recovery key. Their browser opens the copy with their own suite key, opens the enrolment envelope with the recovery key, seals the user's private key PEM to the request public key with HPKE (`info` `keepiq-account-recovery-v1`, `aad` the request id), posts the sealed result, and discards everything. + +The user's browser (the one that made the request) fetches the sealed result, opens it with the request private key, asks for a new master password under the existing strength rules, wraps the private key with it, and calls `PUT /api/v1/suites/{id}/private-key` with a vault-key proof signed by the recovered key. The server marks the request fulfilled and deletes the sealed result. + +### D5: Policy and enrolment + +App config `account_recovery_policy`: `off` (default), `optional` or `required`. Under `optional` a user enrols or withdraws in their personal settings. Under `required` the web app enrols at the next unlock and tells the user, and withdrawal is refused. Before enrolling, the browser shows the recovery certificate's fingerprint, which administrators publish internally, and checks that the certificate chains to the instance CA. + +### D6: Approvals are proven, not just clicked + +`POST /api/v1/recovery/requests/{id}/approve` carries `#[VaultKeyProofRequired(binds: ['id'], subject: 'active', purpose: 'approve-account-recovery')]`, so an approval needs the officer's master password, not just their session. It is added to `VaultKeyProofAttributesTest`. An officer cannot approve a request for their own account. + +### D7: Enrolments and officer copies follow the suite + +A user's enrolment belongs to their suite. After a compromise-recovery rotation the rotating browser, which holds the new key, builds a fresh enrolment to the current recovery certificate, as it re-envelopes emergency contacts. A listener on `SuiteMigrationCompletedEvent` removes enrolments still on the old suite, and one on `EncryptionSuiteRevokedEvent` removes the revoked suite's enrolment and its open requests. + +An officer's wrapped copy is migrated the same way during their own rotation. Removing an officer deletes their copy; because they may have opened it before, the admin section offers to rotate the recovery key. Rotation creates a new key; each enrolled user's browser re-enrols at the next unlock; the old key is retired once no enrolment uses it, or at a deadline the administrator sets. + +### D8: Revocation remains the fallback + +`AdminSuiteSection.vue` warns when the suite being force-revoked belongs to an enrolled user: "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment." ADR-005's endpoint does not change. + +### Endpoints + +Admin (`#[AuthorizedAdminSetting(AdminSettings::class)]` and `#[PasswordConfirmationRequired]`): set officers, threshold and policy; retire a recovery key. Officer (`#[NoAdminRequired]`, in-body officer check): create the recovery key, list requests, approve (D6), decline, fetch the handoff material, post the sealed result, migrate their copy. User (`#[NoAdminRequired]`, own records only): read and write their enrolment, create a request, read their request and its sealed result, complete. All under `/api/v1/recovery/`, registered before the SPA catch-all. + +## Security and zero-knowledge + +Stored encrypted: each officer's copy of the recovery private key (hybrid envelope to that officer's suite certificate), each enrolment (the user's suite private key, hybrid envelope to the recovery certificate), and the short-lived sealed handoff (the user's private key, HPKE to the request key, deleted on completion). Stored in plain: the recovery certificate and fingerprint, officer ids, the threshold, the policy, request states, approvals, and the request public key. The server never holds a key that opens any of the ciphertext it stores (ADR-003). + +What this change honestly adds to the trust model: + +- **One officer's browser sees the recovered private key** for the moment of the handoff (D4). That is the price of recovery without a server-held key; Bitwarden and Passbolt have the same property. The user is told who handled it, and the web app offers a key rotation right after recovery. +- **Any single officer can open the recovery key.** The threshold is enforced by the server, which alone releases enrolment envelopes. A colluding server operator and one officer could bypass it. D2 records why Shamir splitting is not chosen now. +- **The recovery certificate and the request key come through the server**, like every recipient certificate in Keepiq sharing and emergency access. The fingerprint check at enrolment (D5) and the verification phrase at recovery (D3) let people detect a swap. + +Nothing here lets an administrator read a vault: an administrator who is not an officer holds no copy, and even officers get only ciphertext without an approved request. + +## Risks / Trade-offs + +- **The request browser must be the completion browser.** The request key lives in that browser's IndexedDB. A user who switches device files a new request. +- **Officers leaving the organisation.** D7's removal plus key rotation handles it; until rotation completes the removed officer may still hold an opened copy. +- **Required policy asks for the master password at unlock anyway**, so enrolment adds no prompt, only a notice. +- **Fewer officers than the threshold** (officers removed) would block every recovery; the admin section refuses a threshold above the officer count and warns when an officer has no active suite. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. The Playwright flow creates two officer users and one user through the existing E2E seed script `tests/e2e/ci-seed.sh`. + +## Migration + +New tables, through a new migration step: + +- `keepiq_recovery_keys`: id, certificate, fingerprint, threshold, status (`active`, `retiring`, `retired`), created_by, created_at, retired_at. +- `keepiq_recovery_officers`: id, recovery_key_id, officer_uid, officer_suite_id, wrapped_private_key (TEXT), added_by, added_at. +- `keepiq_recovery_enrolments`: id, user_id, suite_id, recovery_key_id, envelope (TEXT), enrolled_at. +- `keepiq_recovery_requests`: id, user_id, suite_id, enrolment_id, request_public_key, status (`pending`, `approved`, `fulfilled`, `declined`, `expired`), created_at, expires_at, handled_by, sealed_result (TEXT, nullable), fulfilled_at. +- `keepiq_recovery_approvals`: id, request_id, officer_uid, decision, decided_at, unique on request and officer. + +The `` in `appinfo/info.xml` must bump. diff --git a/openspec/changes/crypto-organisation-account-recovery/proposal.md b/openspec/changes/crypto-organisation-account-recovery/proposal.md new file mode 100644 index 000000000..8b5c59007 --- /dev/null +++ b/openspec/changes/crypto-organisation-account-recovery/proposal.md @@ -0,0 +1,55 @@ +--- +kind: code +--- + +# Organisation account recovery through named recovery officers + +## Why + +A Keepiq user who forgets their master password loses every secret they own. By design no administrator can restore access: the server never holds a usable key (ADR-003), and ADR-005 makes administrator force-revocation the lost-password route, which gives the user a fresh, empty vault. Organisations that keep business credentials in Keepiq need a way back that does not make the server a key holder. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| crypto-11 | Let an administrator restore a user's access after a forgotten master password. | By zero-knowledge design an administrator cannot restore access to a user's secrets. The admin can force-revoke the locked suite so the user can set up a fresh vault, but the old secrets stay unreadable unless the user has an emergency contact or a backup file. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. `src/components/settings/AdminSuiteSection.vue:21` and `:180` only force-revoke a suite. The one escrow in the code is emergency access, which wraps a grantor's private key to a chosen contact's certificate (`src/crypto/emergencyEnvelope.js:52`, `openspec/specs/emergency-access/spec.md`), never to an administrator. The decision records no non-goal: ADR-005 keeps the server keyless, and an opt-in recovery envelope to an organisation recovery certificate keeps it keyless too. + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/OrganizationUsersController.cs:558 PUT organizations/{orgId}/users/{id}/recover-account, :530 reset-password-enrollment; src/Core/AdminConsole/Enums/PolicyType.cs:17 ResetPassword policy ... Enterprise account recovery lets an admin set a new master password for an enrolled member." +- 1Password: "https://support.1password.com/recovery/ : administrators 'select Begin Recovery'; member gets new Secret Key and password" +- Passbolt: "passbolt/passbolt_api@v5.16.0 plugins/PassboltEe/AccountRecovery/config/routes.php:25 organization-policies, :52 requests, :88 POST /account-recovery/responses ... Pro account recovery escrows an encrypted copy of the user key; an admin with the organisation recovery key approves a request to restore access." +- HashiCorp Vault: "hashicorp/vault@v2.1.1 builtin/credential/userpass/path_user_password.go:39 users//password; ui/app/router.js access.method.item edit route for userpass users Note: An admin can set a new password for any userpass user; data is server-encrypted so nothing is lost." + +## What Changes + +- An administrator names recovery officers (Nextcloud users with an active suite) and a threshold of officer approvals, and sets the recovery policy: off, optional or required. +- An officer generates the organisation recovery key pair in their own browser. The certificate is public. The private key is wrapped to each officer's own suite certificate and then discarded; the server never holds it in usable form. +- A user enrols by letting their browser wrap their suite private key to the organisation recovery certificate, exactly as emergency access wraps it to a contact's certificate. Under the required policy the web app enrols at the next unlock and says so. +- A user who forgot their master password files a recovery request from the lock screen. Their browser makes a one-time key pair for the request and shows a verification phrase. +- Officers compare the phrase with the user over a trusted channel and approve with a vault-key proof. Once the threshold is met, one officer's browser opens the enrolment envelope and seals the user's private key to the request key. The user's browser opens it, asks for a new master password, and re-wraps the private key. +- Enrolments and officer copies follow the suite through rotation and revocation, officers can be added or removed, and the recovery key can be rotated. Every step is audited with identifiers only. + +## Capabilities + +### New Capabilities + +- `organisation-account-recovery`: officer-held organisation recovery key, user enrolment, recovery requests with a verification phrase, threshold approval, and client-side handoff of the recovered key. + +### Modified Capabilities + +None. ADR-005's force-revocation stays as it is; the admin suite section only gains a warning when the user is enrolled. + +## Impact + +- **Backend**: a `RecoveryController` and services for keys, officers, enrolments and requests; a new vault-key proof purpose `approve-account-recovery`; listeners on suite migration and revocation; notification subjects for requests and outcomes. +- **Frontend**: an admin section for officers, threshold and policy; an officer page for the key and the request queue; enrolment in the user settings; a "Forgot your master password?" path on the lock screen at /lock. +- **Database**: five new tables; a migration and a `` bump. +- **Security**: the server stores only public certificates and ciphertext; the recovery private key exists in usable form only in an officer's browser; a recovered private key passes through one officer's browser, which the user is told about and offered a key rotation for. +- **Cross-app**: none. diff --git a/openspec/changes/crypto-organisation-account-recovery/specs/organisation-account-recovery/spec.md b/openspec/changes/crypto-organisation-account-recovery/specs/organisation-account-recovery/spec.md new file mode 100644 index 000000000..f11a47c3b --- /dev/null +++ b/openspec/changes/crypto-organisation-account-recovery/specs/organisation-account-recovery/spec.md @@ -0,0 +1,125 @@ +## ADDED Requirements + +### Requirement: Administrators name recovery officers, a threshold and a policy + +The system MUST let an administrator, after Nextcloud password confirmation, name recovery officers from Nextcloud users with an active encryption suite, set an approval threshold between 1 and the number of officers, and set `account_recovery_policy` to `off` (default), `optional` or `required`. It MUST refuse a threshold above the officer count. + +#### Scenario: Administrator sets up two-person recovery + +- **GIVEN** an administrator on the account recovery section of the Keepiq admin settings who has confirmed their password +- **WHEN** they name officers `olga` and `omar`, set the threshold to 2 and the policy to `optional` +- **THEN** the settings MUST be stored and both officers MUST be notified that they are recovery officers + +#### Scenario: A threshold above the officer count is refused + +- **GIVEN** two named officers +- **WHEN** an administrator sets the threshold to 3 +- **THEN** the system MUST reject the change with a bad-request response + +### Requirement: The recovery private key is generated and held by officers only + +An officer MUST generate the organisation recovery key pair in their own browser. The system MUST store the recovery certificate and, per officer, the recovery private key wrapped to that officer's suite certificate, and MUST NOT receive the recovery private key in any other form. The system MUST return an officer's wrapped copy only to that officer. + +#### Scenario: Officer creates the recovery key + +- **GIVEN** officer `olga` with an unlocked vault on the recovery officer page +- **WHEN** she creates the organisation recovery key +- **THEN** the server MUST store a certificate and one wrapped copy for each named officer +- **AND** no request from her browser MUST contain the recovery private key unwrapped + +### Requirement: Users enrol by wrapping their own key to the recovery certificate + +Under the `optional` policy a user MUST be able to enrol and withdraw in their personal settings; under `required` the web app MUST enrol the user at their next unlock and MUST refuse withdrawal. Enrolment MUST happen in the user's browser: it MUST show the recovery certificate fingerprint, check that the certificate chains to the instance CA, wrap the user's suite private key to the recovery certificate with the emergency-access hybrid envelope, and send only that envelope. + +#### Scenario: Required policy enrols at unlock + +- **GIVEN** the policy is `required` and a user who is not enrolled +- **WHEN** the user unlocks their vault at /lock with their master password +- **THEN** the web app MUST post an enrolment envelope and tell the user they are enrolled, showing the certificate fingerprint +- **AND** the request MUST NOT contain the master password or the private key in plain + +#### Scenario: Withdrawal under a required policy is refused + +- **GIVEN** the policy is `required` and an enrolled user +- **WHEN** the user calls `DELETE /api/v1/recovery/enrolment` +- **THEN** the system MUST refuse and keep the enrolment + +### Requirement: A recovery request carries a one-time key and a verification phrase + +An enrolled user whose vault is locked MUST be able to file a recovery request from the lock screen. The user's browser MUST generate a one-time X25519 key pair, keep the private key in that browser only, and send the public key. The request MUST expire after 72 hours. The user's screen and every officer's approval dialog MUST show the same verification phrase derived from the request public key. + +#### Scenario: Forgotten password starts a request + +- **GIVEN** an enrolled user who forgot their master password +- **WHEN** they choose "Forgot your master password?" on the lock screen at /lock and confirm +- **THEN** a request MUST be created with state `pending` and a 72 hour expiry +- **AND** the lock screen MUST show a verification phrase, and every officer MUST be notified + +### Requirement: Recovery needs a threshold of proven officer approvals + +The system MUST count an approval only when it carries a vault-key proof from the approving officer's active suite for purpose `approve-account-recovery`, MUST count each officer once, MUST refuse an officer's approval of their own request, and MUST move a request to `approved` only when distinct approvals reach the threshold. Any officer MAY decline, which ends the request. + +#### Scenario: Two officers approve + +- **GIVEN** a pending request, a threshold of 2, and officers `olga` and `omar` who each compared the verification phrase with the user by phone +- **WHEN** both approve with a valid vault-key proof +- **THEN** the request MUST move to `approved` + +#### Scenario: An approval without a proof is refused + +- **GIVEN** a pending request +- **WHEN** an officer calls `POST /api/v1/recovery/requests/{id}/approve` with a valid session but no vault-key proof +- **THEN** the system MUST refuse the approval and the count MUST stay unchanged + +#### Scenario: Self-approval is refused + +- **GIVEN** officer `olga` who filed a recovery request for her own account +- **WHEN** she tries to approve it +- **THEN** the system MUST refuse the approval + +### Requirement: The recovered key reaches only the requesting browser + +For an `approved` request the system MUST release the user's enrolment envelope and the officer's own wrapped recovery key only to an officer who approved it. That officer's browser MUST seal the user's private key to the request public key with HPKE and post only the sealed result. The system MUST release the sealed result only to the requesting user. The user's browser MUST open it with the request private key, set a new master password, and replace the suite's private-key wrapping through `PUT /api/v1/suites/{id}/private-key` with a vault-key proof by the recovered key. The system MUST delete the sealed result when the request completes. + +#### Scenario: User completes the recovery + +- **GIVEN** an approved request whose sealed result an approving officer has posted +- **WHEN** the user, in the browser that filed the request, opens the recovery screen and sets a new master password +- **THEN** their suite MUST be re-wrapped under the new password and their existing secrets MUST decrypt +- **AND** the request MUST be `fulfilled` and the sealed result MUST no longer be stored + +#### Scenario: Nobody else can fetch the handoff material + +- **GIVEN** an approved request +- **WHEN** a user who is not an approving officer asks for the handoff material, or anyone but the requester asks for the sealed result +- **THEN** the system MUST refuse with the same response it gives for an unknown request + +### Requirement: The user is told what happened and offered a rotation + +After completion the web app MUST tell the user which officer handled the recovery and MUST offer a compromise-recovery key rotation. Every recovery step MUST be recorded in the audit trail with identifiers only. + +#### Scenario: Recovery notice + +- **GIVEN** a user who just completed a recovery handled by officer `omar` +- **WHEN** the vault opens +- **THEN** the web app MUST show that `omar` handled the recovery and offer to rotate the vault key + +### Requirement: Enrolments and officer copies follow the suite + +A compromise-recovery rotation MUST rebuild the user's enrolment for the new suite in the rotating browser, and the system MUST delete enrolments left on the old suite once the migration completes. Revoking a suite MUST delete its enrolment and end its open requests. An officer's rotation MUST re-wrap their recovery copy to their new suite. Removing an officer MUST delete their copy and the admin section MUST offer a recovery key rotation, after which each enrolled user's browser re-enrols at its next unlock. + +#### Scenario: Rotation keeps the user enrolled + +- **GIVEN** an enrolled user who completes a compromise-recovery rotation +- **WHEN** the migration completes +- **THEN** exactly one enrolment MUST exist for the user, bound to the new suite + +### Requirement: Force-revocation warns about enrolled users + +The administrator suite section MUST warn, before a force-revocation, that the suite's owner is enrolled in account recovery and that recovery keeps their secrets while revocation deletes the enrolment. + +#### Scenario: Warning before revoking an enrolled user's suite + +- **GIVEN** an administrator in the encryption suites section of the Keepiq admin settings +- **WHEN** they enter the suite id of an enrolled user +- **THEN** the section MUST show the enrolled-user warning before the force-revoke action diff --git a/openspec/changes/crypto-organisation-account-recovery/tasks.md b/openspec/changes/crypto-organisation-account-recovery/tasks.md new file mode 100644 index 000000000..83d042e8a --- /dev/null +++ b/openspec/changes/crypto-organisation-account-recovery/tasks.md @@ -0,0 +1,44 @@ +# Tasks: organisation account recovery + +## 1. Data and configuration + +- [ ] 1.1 Add the five recovery tables with entities and mappers, a migration step and a `` bump. Verify: a PHPUnit migration test asserts each table and the unique approval index. +- [ ] 1.2 Add `account_recovery_policy` (default `off`), officer list and threshold handling to the admin settings service, refusing a threshold above the officer count and officers without an active suite. Verify: PHPUnit for each accept and reject path. + +## 2. Recovery key + +- [ ] 2.1 Add the officer endpoint that stores a recovery certificate issued through `CertificateIssuanceService::signPublicKey()` and one wrapped copy per officer, refusing a copy for a non-officer. Verify: PHPUnit asserts the stored copies and that no endpoint returns another officer's copy. +- [ ] 2.2 Add the officer page action that generates the key pair in the browser, wraps it for every officer, posts, and discards the key. Verify: vitest asserts the request body holds only wrapped copies and a public key. +- [ ] 2.3 Add officer add and remove, and recovery key rotation and retirement (D7). Verify: PHPUnit for removal deleting the copy and for a retired key refusing new enrolments. + +## 3. Enrolment + +- [ ] 3.1 Add the user enrolment endpoints (read, write, withdraw; withdraw refused under `required`). Verify: PHPUnit for each policy value. +- [ ] 3.2 Add enrolment in the user settings and at unlock under `required`, showing the certificate fingerprint and building the envelope with `buildRecoveryEnvelope`. Verify: vitest asserts the posted envelope opens with the recovery private key in a test and that no request carries the private key PEM. + +## 4. Requests and approvals + +- [ ] 4.1 Add request creation from the lock screen at /lock, the X25519 request key in IndexedDB, the 72 hour expiry, and the verification phrase. Verify: vitest for the phrase derivation and the stored key; PHPUnit for expiry. +- [ ] 4.2 Add approve (with the `approve-account-recovery` vault-key proof, listed in `VaultKeyProofAttributesTest`) and decline, refusing self-approval and counting distinct officers. Verify: PHPUnit for the proof requirement, self-approval, duplicate approvals and the threshold. +- [ ] 4.3 Release handoff material only to an approving officer after the threshold is met, accept the sealed result, and release it only to the requesting user. Verify: PHPUnit for every wrong-state and wrong-caller refusal, answered identically. +- [ ] 4.4 Add the officer approval dialog with the phrase and the handoff in the browser (D4). Verify: vitest asserts the sealed result opens with the request key and that the officer page keeps no key after posting. +- [ ] 4.5 Add completion: open the sealed result, set a new master password, call `PUT /api/v1/suites/{id}/private-key` with a proof by the recovered key, and offer a key rotation. Verify: vitest for the completion calls; PHPUnit asserts the sealed result is deleted on completion. + +## 5. Lifecycle, audit and notifications + +- [ ] 5.1 Add listeners that remove old-suite enrolments after a migration and a revoked suite's enrolment and open requests, and the re-enrolment step in the rotation flow. Verify: PHPUnit for both listeners; vitest for the rotation step. +- [ ] 5.2 Add audit events for key, officer, enrolment, request, approval and completion (identifiers only) and notification subjects for new requests and outcomes. Verify: PHPUnit asserts no audit metadata holds an envelope or key. +- [ ] 5.3 Add the enrolled-user warning to `AdminSuiteSection.vue`. Verify: vitest renders the warning for an enrolled user's suite. + +## 6. End to end + +- [ ] 6.1 Add a Playwright flow: a user enrols, forgets their password, files a request, two officers approve after comparing the phrase, and the user sets a new master password and reads their old secrets. Verify: the Playwright spec passes in the E2E job. + +## Acceptance criteria + +- An enrolled user who forgot their master password regains their vault with the same key pair and every secret readable. +- The server stores only certificates, public keys and ciphertext; no stored value opens without a key the server does not have. +- A recovery needs the configured number of distinct officer approvals, each proven with the officer's own vault key, and an officer cannot approve their own recovery. +- The recovered private key reaches only the browser that filed the request, sealed to that request's key. +- The user sees the verification phrase, is told who handled the recovery, and is offered a key rotation. +- Enrolments and officer copies survive routine password changes and are rebuilt or removed on rotation and revocation. diff --git a/openspec/changes/sharing-federated-recipients/.openspec.yaml b/openspec/changes/sharing-federated-recipients/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/sharing-federated-recipients/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/sharing-federated-recipients/design.md b/openspec/changes/sharing-federated-recipients/design.md new file mode 100644 index 000000000..3eab9496a --- /dev/null +++ b/openspec/changes/sharing-federated-recipients/design.md @@ -0,0 +1,89 @@ +# Design: federated recipients + +## Context + +Code at development `4c214a9d`: + +- Local sharing is client-side re-encryption (ADR-003): the browser asks `GET /api/v1/shares/recipient-certificate` or `POST /api/v1/shares/recipient-certificates` (`lib/Controller/ShareController.php:309` and `:366`) for a local user's active certificate (`lib/Service/RecipientSecretCopyFactory.php` `certificateFor`), encrypts the value, and the server stores the recipient's copy as a `Secret` owned by that recipient (`lib/Service/RecipientSecretCopyService.php:112` to `:113`) linked by a `ShareTarget`. Sync-on-update re-encrypts for every recipient in the editor's browser (`openspec/specs/user-sharing/spec.md`, requirement "Sync on Update"). +- Certificates are issued by the instance's own CA (`lib/Service/CertificateIssuanceService.php:114`); the user's common name is their federated cloud id when available (`lib/Service/EncryptionSuiteProvisioningService.php:331`). +- The public discovery document is `GET /api/v1/app/.well-known/keepiq` (`lib/Controller/DiscoveryController.php:134`, `#[PublicPage]`). +- Keepiq declares Nextcloud 32 to 34 (`appinfo/info.xml:107`). In Nextcloud's public API, `ICloudFederationProviderManager::addCloudFederationProvider()` and `sendNotification()` exist since 14, `sendCloudShare()` since 29, `IOCMDiscoveryService::discover()` since 28, and `IOCMDiscoveryService::getIncomingSignedRequest()` and `requestRemoteOcmEndpoint()` since 33. +- No OCM provider is registered in `lib/AppInfo/Application.php`. + +## Goals / Non-Goals + +**Goals:** + +- Share a secret from one Keepiq instance to a named user on another Keepiq instance, with end-to-end encryption between the two browsers. +- Keep the owner's later changes flowing to the remote copy, and make revocation remove it. +- Give both administrators control over which instances their users exchange secrets with. + +**Non-Goals:** + +- Sharing with someone who has no Keepiq at all. The public link and secret send stay the tools for that. +- Remote recipients editing the shared secret. Remote copies are read-only in this change. +- Federated groups, federated team folders and federated link shares. +- Trusting a partner automatically on first contact. + +## Decisions + +### D1: Partners are an explicit, pinned allowlist + +An administrator adds a partner by its base URL. Keepiq reads the partner's discovery document, which gains a `federation` block (enabled flag and the SHA-256 fingerprint of the partner's Keepiq root certificate), and shows the fingerprint for the administrator to confirm out of band before saving. The partner row records the URL, the pinned fingerprint, and whether outbound and inbound are allowed. With no partner, federation is off. + +Alternative considered: trust on first use for any instance a user names. Rejected: the pinned root is what lets the browser verify a remote certificate at all (D3). + +### D2: Federation needs Nextcloud 33 + +Every server-to-server call in this feature is a signed OCM request: outbound through `IOCMDiscoveryService::requestRemoteOcmEndpoint()`, inbound checked with `getIncomingSignedRequest()`. Both exist from Nextcloud 33. On Nextcloud 32 the partner section says federation needs Nextcloud 33 and stays off. Keepiq's declared range is unchanged. + +### D3: Certificate lookup goes server to server and is verified in the browser + +The owner types `bob@cloud.partner.example`. Keepiq parses it with `ICloudIdManager`, finds the partner (outbound allowed), and calls the partner's new route `GET /api/v1/federation/recipient-certificate?cloudId=` as a signed OCM request. The partner answers only callers on its own inbound allowlist, and only for users whose Keepiq setting allows receiving from other organisations; any other case answers like an unknown user. The answer is Bob's active certificate and its CA chain. + +The owner's browser checks that the chain ends at the pinned partner root and that the certificate's common name is Bob's cloud id, and shows the certificate fingerprint so the owner can compare it with Bob if they want. Only then does it encrypt. + +### D4: Delivery over OCM with a pull of the ciphertext + +The owner's browser encrypts `key`, `login` and `additionalFields` with Bob's certificate and posts them with the plain `name` and `url` to `POST /api/v1/secrets/{id}/federated-shares`. The server stores an outbound share row and sends an OCM share (resource type `keepiq-secret`, share type `user`) with `sendCloudShare()`, carrying a random shared secret and the owner's cloud id, but not the ciphertext. + +Bob's server, in the registered `ICloudFederationProvider::shareReceived()`, checks the sender is an inbound partner, stores a pending inbound row, and notifies Bob. When Bob accepts in "Incoming from other organisations", his server fetches the ciphertext from the sender's `GET /api/v1/federation/shares/{id}` with the shared secret in a signed request, and stores it as a `Secret` owned by Bob with a read-only flag and the sender's cloud id. Bob decrypts it in his browser with his own key, as any secret. + +Alternative considered: putting the ciphertext in the OCM share body. Rejected: a pull lets the receiving server fetch only after acceptance, and lets updates reuse the same route. + +### D5: Updates, revocation and expiry + +When the owner updates the secret, the browser's sync step also encrypts for each federated recipient, using the certificate fetched again through D3, and posts it to the outbound row; the server sends an OCM notification `SHARE_UPDATED`, and the receiving server pulls again (`notificationReceived()`). Revoking sends `SHARE_UNSHARED` and the receiving server deletes the copy. If the partner is removed or the recipient's certificate no longer verifies, the owner is told and the share is suspended until they revoke or re-share. A failed notification is retried by a background job with backoff and shown to the owner after the last attempt. + +### D6: Policy on both sides + +Instance level: the partner allowlist (D1). User level: a personal setting "Receive secrets from other organisations" (default off) that D3 checks. Owner side: the share dialog offers federated recipients only when at least one outbound partner exists. + +## Security and zero-knowledge + +Between instances travel only: OCM share metadata (cloud ids, share id, shared secret, resource type), certificates, and ciphertext encrypted in the owner's browser for the recipient's certificate. Neither server can decrypt a value; the owner's server never holds the recipient's private key and the recipient's server never holds the owner's (ADR-003). + +Stored on the sending side: the outbound share row with the recipient cloud id, partner id, recipient certificate fingerprint, status, the hashed shared secret, and the latest ciphertext for the recipient (needed for the pull). Stored on the receiving side: the inbound row with sender cloud id, partner id, remote share id, the shared secret encrypted with `ICrypto` (the server must present it unattended), status, and the local copy's id. The `name` and `url` of a shared secret are plain on both sides, as they are for local shares. + +What a partner could still do: a malicious partner server could issue a certificate for its own user that the owner's browser accepts, because the owner trusts the partner's root by design. The fingerprint display and the administrator's explicit allowlist are the controls; the owner shares only with instances their administrator approved. + +## Risks / Trade-offs + +- **Two administrators must act before anyone can share.** Deliberate: federation of secrets should never be on by accident. +- **Nextcloud 32 instances cannot federate.** Stated in the admin section. +- **Remote copies lag if a notification fails.** Retries and an owner-visible failure state cover it; the remote copy is never partially updated because the pull replaces it whole. +- **A recipient cannot edit.** Remote editing would need the recipient's browser to encrypt for the owner and every other recipient across instances; a later change. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. The integration test runs two Nextcloud 33 containers on one Docker network, each with Keepiq and a seeded user, set up by the test itself; no fixture data is committed. + +## Migration + +New tables through a new migration step: + +- `keepiq_federation_partners`: id, base_url, root_fingerprint, allow_outbound, allow_inbound, added_by, added_at. +- `keepiq_federated_shares` (outbound): id, source_secret_id, owner_id, recipient_cloud_id, partner_id, recipient_cert_fingerprint, key, login, additional_fields (ciphertext for the recipient), shared_secret_hash, status, created_at, updated_at. +- `keepiq_federated_inbound`: id, recipient_uid, sender_cloud_id, partner_id, remote_share_id, shared_secret_enc, secret_id, status (`pending`, `accepted`, `declined`, `revoked`), received_at. + +`secrets` gains `federated_source` (nullable `STRING(255)`, the sender cloud id) and `read_only` (boolean, default false). The `` in `appinfo/info.xml` must bump. diff --git a/openspec/changes/sharing-federated-recipients/proposal.md b/openspec/changes/sharing-federated-recipients/proposal.md new file mode 100644 index 000000000..74bc3164e --- /dev/null +++ b/openspec/changes/sharing-federated-recipients/proposal.md @@ -0,0 +1,53 @@ +--- +kind: code +--- + +# Share a secret with a user on another Nextcloud instance + +## Why + +Keepiq shares only with users and groups on the same Nextcloud instance. A municipality that works with a partner organisation on its own Nextcloud has to fall back to a password-protected public link, a snapshot that does not follow later changes and is not tied to a person. Nextcloud already federates files between instances through Open Cloud Mesh (OCM); Keepiq does not use it. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| sharing-17 | Share with someone outside the organisation who has their own account elsewhere. | Sharing with an account on a different Nextcloud instance is not supported; a password-protected public link (sharing-14) is the closest substitute. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. Every sharing path targets a local user, a local group, or an anonymous link (`lib/Controller/ShareController.php`, `GroupShareController.php`, `LinkShareController.php`). The only federated cloud id in the code names a certificate's common name (`lib/Service/EncryptionSuiteProvisioningService.php:331`); nothing registers an OCM provider. + +### Demand + +No demand row. + +### Competitors rated yes + +- 1Password: "https://support.1password.com/share-items/ : recipient can save the shared item into their own 1Password account; guest accounts in https://support.1password.com/custom-groups/" +- Keeper: "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#creating-and-sharing : policies 'Share to users outside of the enterprise' and 'Receive items from users outside of the enterprise'" + +## What Changes + +- Administrators list trusted partner instances, pin each partner's Keepiq root certificate fingerprint, and allow outbound, inbound or both. Federation is off until a partner is added. +- A vault owner can share a secret with a federated cloud id (`bob@cloud.partner.example`). Their browser fetches Bob's certificate through their own server from the partner, checks it against the pinned root, shows its fingerprint, and encrypts the secret for Bob. Only ciphertext leaves the browser. +- Keepiq registers an OCM resource type `keepiq-secret`. The sending server announces the share over OCM; the receiving server fetches the ciphertext with the share's shared secret and, once Bob accepts, stores it as a secret in Bob's vault. +- Updates by the owner are re-encrypted in the owner's browser for the remote recipient and announced over OCM; revocation and expiry remove the remote copy. +- Remote copies are read-only for the recipient in this change. +- Nextcloud 33 or later is required for federation, so every server-to-server call is a signed OCM request. + +## Capabilities + +### New Capabilities + +- `federated-sharing`: trusted partner instances, federated certificate lookup, OCM share delivery, acceptance, sync, revocation, and policy. + +### Modified Capabilities + +None. Local sharing stays as specified in `user-sharing`. + +## Impact + +- **Backend**: an OCM provider registered with `OCP\Federation\ICloudFederationProviderManager::addCloudFederationProvider()`, outbound shares through `sendCloudShare()` and `sendNotification()`, partner discovery through `OCP\OCM\IOCMDiscoveryService`, request verification through `IOCMDiscoveryService::getIncomingSignedRequest()`, cloud id parsing through `OCP\Federation\ICloudIdManager`; new controllers and services for partners, federated shares and inbound shares. +- **Frontend**: a federated recipient option in the share dialog, a partner list in the admin settings, and an "Incoming from other organisations" list for accepting shares. +- **Database**: three new tables; a migration and a `` bump. +- **Security**: ciphertext only between instances; certificates checked against a pinned partner root; signed OCM requests; administrator allowlists in both directions. +- **Cross-app**: none. Nextcloud's own federated file sharing is untouched. diff --git a/openspec/changes/sharing-federated-recipients/specs/federated-sharing/spec.md b/openspec/changes/sharing-federated-recipients/specs/federated-sharing/spec.md new file mode 100644 index 000000000..d47712558 --- /dev/null +++ b/openspec/changes/sharing-federated-recipients/specs/federated-sharing/spec.md @@ -0,0 +1,92 @@ +## ADDED Requirements + +### Requirement: Administrators approve and pin partner instances + +The system MUST let an administrator, after Nextcloud password confirmation, add a partner instance by URL, MUST read and show the partner's Keepiq root certificate fingerprint from its discovery document for confirmation, and MUST store the pinned fingerprint with separate outbound and inbound permissions. With no partner, the system MUST NOT offer, send or accept any federated share. On a Nextcloud version below 33 the section MUST explain that federation needs Nextcloud 33 and MUST keep federation off. + +#### Scenario: Administrator adds a partner + +- **GIVEN** an administrator on the federation section of the Keepiq admin settings on Nextcloud 33 +- **WHEN** they add `https://cloud.partner.example`, compare the shown fingerprint with the partner's administrator, and allow outbound and inbound +- **THEN** the partner MUST be stored with the pinned fingerprint and both permissions + +#### Scenario: No partner, no federation + +- **GIVEN** an instance with no partner +- **WHEN** a vault owner opens the share dialog +- **THEN** the dialog MUST NOT offer a federated recipient + +### Requirement: Certificate lookup is signed, allowlisted and verified in the browser + +The system MUST fetch a federated recipient's certificate only from an outbound partner, as a signed OCM request. A partner MUST answer only signed requests from its own inbound partners about users who allow receiving from other organisations, and MUST answer every other case as for an unknown user. The owner's browser MUST refuse a certificate whose chain does not end at the pinned partner root or whose common name is not the recipient's cloud id, and MUST show the certificate fingerprint before encrypting. + +#### Scenario: A verified remote certificate + +- **GIVEN** a vault owner on an instance that pinned `cloud.partner.example`, and `bob@cloud.partner.example` who allows receiving +- **WHEN** the owner enters Bob's cloud id in the share dialog +- **THEN** the browser MUST verify Bob's certificate against the pinned root and show its fingerprint + +#### Scenario: A certificate from another root is refused + +- **GIVEN** a lookup answer whose chain ends at a root other than the pinned one +- **WHEN** the browser checks it +- **THEN** the share dialog MUST refuse to encrypt and say the certificate could not be verified + +#### Scenario: Directory probing learns nothing + +- **GIVEN** an instance that is not an inbound partner of `cloud.partner.example` +- **WHEN** it asks the partner for the certificate of `bob@cloud.partner.example` +- **THEN** the partner MUST answer exactly as it does for a user who does not exist + +### Requirement: Federated shares carry only browser-made ciphertext + +The owner's browser MUST encrypt the value, login and additional fields for the verified recipient certificate and send only that ciphertext with the plain name and URL. The sending server MUST announce the share over OCM with resource type `keepiq-secret` without the ciphertext. The receiving server MUST store an inbound share as pending and notify the recipient, and only after acceptance MUST pull the ciphertext with the share's shared secret in a signed request and store it as a read-only secret owned by the recipient. + +#### Scenario: Bob accepts a shared login + +- **GIVEN** a pending federated share from `alice@cloud.city.example` to Bob +- **WHEN** Bob accepts it under "Incoming from other organisations" +- **THEN** his server MUST pull the ciphertext and store a read-only secret in Bob's vault marked as coming from `alice@cloud.city.example` +- **AND** Bob's browser MUST decrypt it with Bob's own key + +#### Scenario: A non-partner cannot deliver + +- **GIVEN** an OCM share of type `keepiq-secret` from an instance that is not an inbound partner +- **WHEN** Bob's server receives it +- **THEN** it MUST reject the share and store nothing + +### Requirement: Owner updates reach the remote copy and revocation removes it + +When the owner updates a federated shared secret, the owner's browser MUST encrypt the new value for each federated recipient with a freshly verified certificate, and the sending server MUST send an OCM `SHARE_UPDATED` notification after which the receiving server pulls the new ciphertext. Revoking MUST send `SHARE_UNSHARED`, after which the receiving server MUST delete the copy. A share whose partner was removed, or whose recipient certificate no longer verifies, MUST be suspended and shown to the owner. + +#### Scenario: A password change reaches Bob + +- **GIVEN** a federated share accepted by Bob +- **WHEN** Alice changes the password in her vault +- **THEN** Bob's copy MUST show the new password after his server pulls the update + +#### Scenario: Revocation removes Bob's copy + +- **GIVEN** a federated share accepted by Bob +- **WHEN** Alice revokes it +- **THEN** Bob's server MUST delete the copy from Bob's vault + +### Requirement: Remote copies are read-only + +The system MUST refuse update, sync, onward sharing and link-share creation on a secret marked read-only for its owner. + +#### Scenario: Bob cannot edit or pass it on + +- **GIVEN** a read-only federated copy in Bob's vault +- **WHEN** Bob calls `PUT /api/v1/secrets/{id}` or tries to share it +- **THEN** the system MUST refuse the request with a forbidden response + +### Requirement: Users opt in to receiving + +Each user MUST have a personal setting "Receive secrets from other organisations", default off, and the certificate lookup MUST treat a user who has not opted in as unknown. + +#### Scenario: Opted-out user cannot be found + +- **GIVEN** `carol@cloud.partner.example` who has not opted in +- **WHEN** a partner looks up her certificate +- **THEN** the answer MUST be the unknown-user answer diff --git a/openspec/changes/sharing-federated-recipients/tasks.md b/openspec/changes/sharing-federated-recipients/tasks.md new file mode 100644 index 000000000..0e3a1d271 --- /dev/null +++ b/openspec/changes/sharing-federated-recipients/tasks.md @@ -0,0 +1,39 @@ +# Tasks: federated recipients + +## 1. Partners and discovery + +- [ ] 1.1 Add the three federation tables, the `federated_source` and `read_only` columns on `secrets`, a migration step and a `` bump. Verify: a PHPUnit migration test asserts the tables and columns. +- [ ] 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. +- [ ] 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. + +## 2. Certificate lookup + +- [ ] 2.1 Add the partner-facing `GET /api/v1/federation/recipient-certificate` that verifies the signed OCM request, answers only inbound partners and users who allow receiving, and otherwise answers as for an unknown user. Verify: PHPUnit for an unsigned request, a non-partner, a user who opted out and an allowed lookup. +- [ ] 2.2 Add the owner-facing lookup that parses the cloud id with `ICloudIdManager`, calls the partner through `IOCMDiscoveryService::requestRemoteOcmEndpoint()`, and returns the certificate chain. Verify: PHPUnit with a mocked discovery service. +- [ ] 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. + +## 3. Delivery + +- [ ] 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. +- [ ] 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. +- [ ] 3.3 On acceptance, pull the ciphertext from the sender's `GET /api/v1/federation/shares/{id}` with the shared secret in a signed request, and store a read-only `Secret` owned by the recipient. Verify: PHPUnit for the pull, the stored flags, and refusal of a wrong shared secret. +- [ ] 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. + +## 4. Sync and revocation + +- [ ] 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. +- [ ] 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. +- [ ] 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. + +## 5. End to end + +- [ ] 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. + +## Acceptance criteria + +- A vault owner can share a secret with a user on an approved partner instance, and the recipient reads it in their own vault after accepting. +- Only ciphertext made in the owner's browser for the recipient's verified certificate crosses between instances. +- A certificate that does not chain to the pinned partner root is refused in the browser. +- Updates reach the remote copy, and revocation removes it. +- Nothing federates until both administrators add each other as partners, and a user receives only after opting in. +- Federation stays off on Nextcloud 32. diff --git a/openspec/changes/sharing-team-folder-manager-role/.openspec.yaml b/openspec/changes/sharing-team-folder-manager-role/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/sharing-team-folder-manager-role/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/sharing-team-folder-manager-role/design.md b/openspec/changes/sharing-team-folder-manager-role/design.md new file mode 100644 index 000000000..1008024af --- /dev/null +++ b/openspec/changes/sharing-team-folder-manager-role/design.md @@ -0,0 +1,77 @@ +# Design: team-folder manager role + +## Context + +Code at development `4c214a9d`: + +- `lib/Service/TeamFolderQueryService.php:110` `loadOwnedTeamFolder()` refuses anyone but the owner. It guards `unshareFolder` (`lib/Service/TeamFolderService.php:149`), `addMember` (`:222`), `removeMember` (`:314`), `reconcile` (`:387`), `registerFanOutShares` (`:424`), `approveJoin` (`:497`) and `setMemberGrade` (`:582`). +- `setMemberGrade()` accepts `read` or `write` only (`:578`). +- `lib/Db/TeamFolderMember.php:144` `effectiveGrade()` returns `write` for `write` and `read` for anything else, so a stored `manage` would silently act as `read` today. +- `lib/Service/TeamFolderQueryService.php:256` `resolveGrade()` walks the ancestor chain and returns `write` at the first write grant, else `read`. +- Write-grade checks: `lib/Service/ShareService.php:312` (`!== 'write'` hides the recipient list), `lib/Service/ShareSyncService.php:174` and `:211` (`write` or `owner`). +- `addMember()` returns the new users' certificates and the subtree's secret refs; the caller's browser encrypts every secret for every new user and posts them to `registerFanOutShares()`, which accepts rows only for secrets in the subtree and never for the owner (`lib/Service/TeamFolderShareService.php:278` to `:281`). A member already holds a recipient copy of each folder secret (`TeamFolderShareService.php:295`). +- The `team_folder_members.grade` column is `STRING(8)`, default `read` (`lib/Migration/Version001000Date20260908000000.php:732`). +- `src/modals/TeamFolderDialog.vue:65` to `:74` renders the grade select (Read, Write) for the owner only. + +## Goals / Non-Goals + +**Goals:** + +- A team folder can have managers who keep its membership current without the owner. +- Viewer, Editor and Manager as the words people see. +- The owner keeps the last word: only the owner makes or unmakes managers and ends the folder. + +**Non-Goals:** + +- A manager adding their own secrets to the owner's folder. Secrets in a team folder stay owned by the folder owner; moving a secret in stays an owner action. +- Ownership transfer. The existing handover and offboarding paths cover it. +- Per-secret roles, and narrowing a subfolder's grade below an ancestor's (still out of scope, as in the existing spec). +- Managers on user shares or group shares outside team folders. + +## Decisions + +### D1: `manage` is a grade, ranked above `write` + +The membership `grade` takes `read`, `write` or `manage`. `effectiveGrade()` returns the stored value when it is one of the three and `read` otherwise. `resolveGrade()` returns the highest along the ancestor chain with the ranking `read` < `write` < `manage`, stopping early only at `manage`. Every check that asks for `write` accepts `manage` too. + +Alternative considered: a separate `is_manager` flag beside the grade. Rejected: a manager must also be able to edit, so a flag would allow the meaningless pair "read plus manager" and need a second ranking rule. + +### D2: One guard for manageable folders + +`TeamFolderQueryService::loadManageableTeamFolder(teamFolderId, userId)` returns the folder when the caller is the owner or has an effective `manage` grade on it (through its own membership or an ancestor's, per D1). `addMember`, `removeMember`, `setMemberGrade`, `approveJoin`, `reconcile` and `registerFanOutShares` switch to it. `unshareFolder` and team-folder deletion keep `loadOwnedTeamFolder()`. + +Within that guard, a manager who is not the owner is refused when they try to: set or clear `manage` on anyone, remove or change a member whose grade is `manage`, or touch the owner. A manager may remove their own membership. + +### D3: Managers fan out from their own copies + +When a manager adds a member, `addMember()` returns the same recipients and secret refs it returns the owner. The manager's browser decrypts its own recipient copy of each folder secret with its own key and encrypts for each new user; `registerFanOutShares()` accepts the rows under the manage guard with the existing subtree and not-the-owner checks. A secret the manager holds no copy of (for example added after the manager joined and not yet fanned out to them) is skipped and appears in the owner's reconcile as missing, which the owner fills as today. + +### D4: Attribution and visibility + +`memberAdded`, member removal and `gradeChanged` audit events already carry an `actorId`; with managers acting, the actor is the manager. The member list already stores `added_by`; the dialog shows it ("Added by Olga"). No extra notification to the owner in this change; the audit trail and the list are the record. + +### D5: Words in the interface + +`TeamFolderDialog.vue` shows Viewer, Editor and Manager. The owner sees all three options; a manager sees Viewer and Editor and sees Manager rows as read-only. A viewer or editor sees no member controls. + +## Security and zero-knowledge + +No new key material, no new ciphertext type. A manager's fan-out is the same operation the owner performs today: decrypt in the browser with the caller's own key, encrypt for recipient certificates, post ciphertext (ADR-003). The server authorizes on grade metadata only and never decrypts. + +What a manager gains is authority, not keys: they could already read every folder secret as a member. The new risk is a manager adding someone the owner would not; D2 keeps the owner above every manager, and D4 records who did what. + +Stored in plain, as today: membership rows with `grade` and `added_by`. Nothing stored encrypted changes. + +## Risks / Trade-offs + +- **A manager can add a member who then reads every folder secret.** That is the point of the role; the owner chooses managers, and the audit trail names the manager. +- **Fan-out gaps when a manager lacks a copy.** Reported through the owner's reconcile rather than failing the add. +- **An old client that sends only `read` and `write`** keeps working; an older server build that sees `manage` would treat it as `read` (D1's context), so this change ships server and client together. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests create a team folder with an owner, a manager, an editor and a viewer. + +## Migration + +None. The `grade` column already holds up to eight characters, so `manage` fits; no table or column changes and no `` bump. When this change is archived, the note at `openspec/specs/folder-permission-grades/spec.md:82` that calls `manage` out of scope must be updated. diff --git a/openspec/changes/sharing-team-folder-manager-role/proposal.md b/openspec/changes/sharing-team-folder-manager-role/proposal.md new file mode 100644 index 000000000..3a9b07ce8 --- /dev/null +++ b/openspec/changes/sharing-team-folder-manager-role/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# A manager role on team folders + +## Why + +A team folder has one person who can change anything about its membership: the owner. Members are viewers (`read`) or editors (`write`). When the owner is on leave or simply busy, nobody else can add a new colleague, remove a leaver, or promote someone to editor. Every competitor that rates yes lets a shared collection have managers. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| sharing-18 | Give people roles such as manager or viewer on a shared collection. | The only role-like concept is a team-folder membership grade of read or write (folder-permission-grades spec, sharing-09); there is no manager or viewer role vocabulary and no collection concept beyond team folders. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. The row record gives no `note`; the text above is its evidence. `openspec/specs/folder-permission-grades/spec.md:82` names a `manage` grade out of scope for v1, a scope boundary of that change and not a non-goal. Every membership action goes through the owner-only guard `TeamFolderQueryService::loadOwnedTeamFolder()` (`lib/Service/TeamFolderQueryService.php:110`), and `setMemberGrade()` accepts only `read` and `write` (`lib/Service/TeamFolderService.php:578`). + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 libs/common/src/admin-console/models/collections/collection-access-selection.view.ts:9 manage vs :7 readOnly; apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts:108 permission options; bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/CollectionsController.cs:190 PUT access Note: Manager (manage collection), editor and viewer roles per collection ..." +- 1Password: "https://support.1password.com/create-share-vaults-teams/ : Allow Managing versus Allow Viewing per person or group" +- Passbolt: "passbolt/passbolt_api@v5.16.0 plugins/PassboltCe/Folders/config/routes.php:72 folder permissions read, update, owner; passbolt/passbolt_api@v5.16.0 config/routes.php:155 PUT /groups/{id} group managers vs members ..." +- Keeper: "https://docs.keeper.io/enterprise-guide/sharing/nested-share-subfolders : roles Viewer, Share Manager, Content Manager, Content and Share Manager, Full Manager" + +## What Changes + +- Team-folder membership gets a third grade, `manage`, next to `read` and `write`. The interface names them Viewer, Editor and Manager. +- A manager can do what an editor can, and can add and remove Viewers and Editors, change a member between Viewer and Editor, approve group joins, and run the fan-out for new members from their own copies. +- Only the owner can grant or revoke Manager, remove a manager, stop sharing the folder, or delete it. A manager can leave. +- The grade ranking becomes `read` < `write` < `manage` along the ancestor chain. +- Every manager action is audited with the manager as actor, and the member list shows who added whom. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `folder-permission-grades`: renames and modifies "Team-folder membership carries a read or write grade" to include `manage` and the manager's authority, modifies the ancestor-chain ranking, and adds requirements for manager actions, owner-only actions and manager fan-out. + +## Impact + +- **Backend**: a manage-aware guard next to `loadOwnedTeamFolder()` for add, remove, grade, approve-join, reconcile and register-shares; `TeamFolderMember::effectiveGrade()`, `TeamFolderQueryService::resolveGrade()`, `ShareService::listSharesForSecret()` and `ShareSyncService` treat `manage` as at least `write`. +- **Frontend**: `src/modals/TeamFolderDialog.vue` shows Viewer, Editor and Manager and shows member controls to managers. +- **Database**: none. The `grade` column (`STRING(8)`) already fits `manage`; no migration, no `` bump. +- **Security**: no new key material; managers fan out from copies they already hold; the server still never sees plaintext. +- **Cross-app**: none. diff --git a/openspec/changes/sharing-team-folder-manager-role/specs/folder-permission-grades/spec.md b/openspec/changes/sharing-team-folder-manager-role/specs/folder-permission-grades/spec.md new file mode 100644 index 000000000..2c0dcb0cc --- /dev/null +++ b/openspec/changes/sharing-team-folder-manager-role/specs/folder-permission-grades/spec.md @@ -0,0 +1,86 @@ +## RENAMED Requirements + +- FROM: `### Requirement: Team-folder membership carries a read or write grade` +- TO: `### Requirement: Team-folder membership carries a read, write or manage grade` + +## MODIFIED Requirements + +### Requirement: Team-folder membership carries a read, write or manage grade + +The system MUST record a `read` (default), `write` or `manage` grade on every team-folder membership, shown in the interface as Viewer, Editor and Manager. A `read` grade MUST grant exactly the access team-folder-sharing grants today. A `write` grade MUST additionally authorize value updates that propagate to all recipients. A `manage` grade MUST include everything `write` allows and MUST authorize membership management within the limits of the requirement "Only the owner governs managers and the folder itself". Only the folder owner, or a member whose effective grade on the folder is `manage`, MUST be able to set or change a grade. + +#### Scenario: New membership defaults to read + +- **GIVEN** an owner shares a folder without specifying a grade +- **WHEN** the membership is created +- **THEN** the system MUST set the grade to `read` and the member MUST NOT be able to push a value update to the team + +#### Scenario: Non-owner cannot change a grade + +- **GIVEN** a member of a shared folder who is not its owner and whose effective grade is `read` or `write` +- **WHEN** they attempt to change any member's grade +- **THEN** the system MUST reject the request with a forbidden response + +#### Scenario: A manager promotes a viewer to editor + +- **GIVEN** a member Olga with grade `manage` on a team folder, and a member Bob with grade `read` +- **WHEN** Olga calls `PATCH /api/v1/team-folders/{id}/members/{memberId}` for Bob with grade `write` +- **THEN** Bob's grade MUST become `write` + +### Requirement: Effective grade is the highest grade along the ancestor folder chain + +The system MUST compute a member's effective grade for a secret or a team folder as the highest grade granted by any ancestor team folder, with `manage` above `write` above `read`. A subfolder MAY raise the grade; it MUST NOT lower it below any ancestor's grade. + +#### Scenario: Subfolder raises the effective grade + +- **GIVEN** folder F grants member M `read`, and subfolder T of F grants M `write` +- **WHEN** the effective grade for a secret in T is resolved for M +- **THEN** it MUST be `write` + +#### Scenario: An ancestor manager outranks a subfolder editor + +- **GIVEN** folder F grants member M `manage`, and subfolder T of F grants M `write` +- **WHEN** the effective grade for T is resolved for M +- **THEN** it MUST be `manage` + +## ADDED Requirements + +### Requirement: Managers keep the membership current + +A member with an effective `manage` grade MUST be able to add users and groups as Viewers or Editors, remove Viewers and Editors, change a member between `read` and `write`, approve a group join, and run reconcile, on the team folder and its subtree. When a manager adds a member, the manager's browser MUST encrypt each folder secret for the new users from the manager's own recipient copies and post only ciphertext; the system MUST accept those rows under the existing subtree and not-the-owner checks. Secrets the manager holds no copy of MUST be skipped and reported, and MUST appear as missing in the owner's reconcile. + +#### Scenario: A manager adds a colleague while the owner is away + +- **GIVEN** a team folder owned by Anna, with Olga as Manager and three secrets that Olga holds copies of +- **WHEN** Olga adds Bob as a Viewer in the team folder dialog +- **THEN** Bob MUST receive a recipient copy of all three secrets, encrypted in Olga's browser +- **AND** the audit trail MUST record Olga as the actor of the member addition + +#### Scenario: A missing copy is reported, not faked + +- **GIVEN** a manager who holds no copy of one folder secret +- **WHEN** they add a new member +- **THEN** that secret MUST be listed as skipped to the manager +- **AND** the owner's reconcile MUST list the pair as missing + +### Requirement: Only the owner governs managers and the folder itself + +The system MUST refuse, for a member who is not the owner, setting or clearing the `manage` grade, removing or changing a member whose grade is `manage`, changing or removing the owner, stopping sharing of the folder, and deleting the team folder. A manager MUST be able to remove their own membership. + +#### Scenario: A manager cannot create another manager + +- **GIVEN** Olga with grade `manage` and Bob with grade `read` +- **WHEN** Olga tries to set Bob's grade to `manage` +- **THEN** the system MUST reject the request with a forbidden response and Bob's grade MUST stay `read` + +#### Scenario: A manager cannot unshare the folder + +- **GIVEN** Olga with grade `manage` +- **WHEN** she calls `DELETE /api/v1/team-folders/{id}` +- **THEN** the system MUST reject the request and the team folder MUST remain + +#### Scenario: A manager leaves + +- **GIVEN** Olga with grade `manage` +- **WHEN** she removes her own membership +- **THEN** the membership MUST be removed and her derived copies revoked as for any member who leaves diff --git a/openspec/changes/sharing-team-folder-manager-role/tasks.md b/openspec/changes/sharing-team-folder-manager-role/tasks.md new file mode 100644 index 000000000..47e141800 --- /dev/null +++ b/openspec/changes/sharing-team-folder-manager-role/tasks.md @@ -0,0 +1,30 @@ +# Tasks: team-folder manager role + +## 1. Grade model + +- [ ] 1.1 Accept `manage` in `TeamFolderMember::effectiveGrade()` and `TeamFolderService::setMemberGrade()`, and rank `read` < `write` < `manage` in `TeamFolderQueryService::resolveGrade()`. Verify: PHPUnit for each grade and for an ancestor `manage` above a subfolder `read`. +- [ ] 1.2 Make every write check accept `manage` (`ShareService::listSharesForSecret()`, `ShareSyncService` at the two grade checks). Verify: PHPUnit asserts a manager can run a value update fan-out like an editor. + +## 2. Guards + +- [ ] 2.1 Add `TeamFolderQueryService::loadManageableTeamFolder()` and switch add, remove, grade, approve-join, reconcile and register-shares to it; keep unshare and delete on the owner guard. Verify: PHPUnit for owner, manager, editor and viewer on each action. +- [ ] 2.2 Refuse a manager setting or clearing `manage`, removing or changing a manager, or touching the owner; allow a manager to remove their own membership. Verify: PHPUnit for each refusal and for self-removal. +- [ ] 2.3 Accept a manager's fan-out rows in `registerFanOutShares()` with the existing subtree and not-the-owner checks. Verify: PHPUnit asserts accepted rows for a manager and refused rows outside the subtree. + +## 3. Interface + +- [ ] 3.1 Show Viewer, Editor and Manager in `src/modals/TeamFolderDialog.vue`, with member controls for managers limited as in D2 and "Added by" on each member. Verify: vitest renders the owner, manager and viewer views. +- [ ] 3.2 Let a manager's browser run the fan-out from its own copies when adding a member, and report skipped secrets. Verify: vitest with a mocked store asserts the rows come from the manager's copies and skipped ones are listed. +- [ ] 3.3 Add a Playwright flow: the owner makes Olga a manager; Olga adds Bob as a viewer; Bob reads a folder secret; Olga cannot make Bob a manager. Verify: the Playwright spec passes in the E2E job. + +## 4. Audit + +- [ ] 4.1 Assert that member added, member removed and grade changed events carry the manager as actor. Verify: PHPUnit on the audit metadata. + +## Acceptance criteria + +- A team-folder membership can be Viewer (`read`), Editor (`write`) or Manager (`manage`). +- A manager can add and remove viewers and editors, change them between the two, approve group joins and run the fan-out for new members. +- Only the owner can grant or revoke Manager, remove a manager, stop sharing the folder or delete it. +- The effective grade along the ancestor chain ranks `manage` above `write` above `read`. +- The server never decrypts anything for a manager action, and every manager action is audited with the manager as actor. diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/.openspec.yaml b/openspec/changes/sharing-use-only-and-expiring-shares/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/design.md b/openspec/changes/sharing-use-only-and-expiring-shares/design.md new file mode 100644 index 000000000..be31121d1 --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/design.md @@ -0,0 +1,91 @@ +# Design: use-only shares and expiring shares + +## Context + +Code at development `4c214a9d`: + +- A share is a recipient-owned `Secret` copy plus a `ShareTarget` row (`share_targets`: source, target user, copy id, optional group share id and team folder id; `lib/Migration/Version001000Date20260908000000.php:644`). Copies are created by `lib/Service/RecipientSecretCopyService.php:75` (owner type `user`, owner id the recipient, `:112` to `:113`). +- Direct shares from the web app go through `POST /api/v1/shares/register-batch` (`lib/Controller/ShareController.php:278`, `lib/Service/DirectShareRegistrar.php:88`); single and batch creates are `ShareController::create` (`:120`) and `createBatch` (`:193`). Group shares are `lib/Controller/GroupShareController.php:103` over `group_shares` (`:366` in the migration, no expiry column). Team-folder members are `TeamFolderMemberController::addMember` (`:119`) and `setMemberGrade` (`:236`, `PATCH /api/v1/team-folders/{id}/members/{memberId}`), over `team_folder_members` (`:724`). +- Revocation deletes the copy and the target row (`lib/Service/ShareRevocationService.php:92`); team-folder member removal revokes derived shares for users no longer covered (`lib/Service/TeamFolderService.php:313`). +- `secrets.expires_at` already exists and means credential expiry for rotation (`:632`); it is not access expiry. +- Read paths a recipient uses: `SecretMapper::findByOwner` (`lib/Db/SecretMapper.php:115`), `findById` (`:76`), `searchByNameOrUrl` (`:396`, the extension match), `findForUnifiedSearch` (`:425`), and the offline manifest (`lib/Service/OfflineManifestService.php:88`). +- Reveal and copy in the web app: `src/components/PasswordField.vue` (reveal toggle), `src/components/CopyButton.vue`, `src/components/SecretDetailSidebar.vue`, `src/components/VersionHistoryPanel.vue`; exports under `src/export/` and `src/cxf/`. The extension decrypts in `browser-extension/src/background/service-worker.js:113` and copies codes in the popup; the CLI reveals in `cli/main.go` (`show` `:180`, `get` `:203`, `copy`). +- Background job pattern: `lib/BackgroundJob/ExpireSecretRequestsJob.php:51` (`TimedJob`, hourly). + +## Goals / Non-Goals + +**Goals:** + +- An owner can let a colleague sign in with a shared login through the extension without Keepiq showing or copying the password to them. +- An owner can give access until a date, after which the server stops serving the copy and removes it. +- Neither flag can be escaped by sharing the copy onward. +- The product tells the owner honestly what use-only does and does not stop. + +**Non-Goals:** + +- Cryptographic use-only. It cannot exist in a vault where the recipient's device fills the password; see the security section. +- Use-only for `write` or `manage` team-folder grades. Editing a value you cannot see is not offered. +- Expiry on public links and sends, which already have their own. +- Hiding the name, URL or login name of a use-only secret. Only the secret value and the additional fields are hidden. + +## Decisions + +### D1: Two flags, set by the sharer, materialised on the copy + +`use_only` (boolean) and `expires_at` (datetime, nullable) are stored on `share_targets` for direct shares, on `group_shares` (inherited by the group's derived targets), and on `team_folder_members`. A `ShareRestrictionResolver` materialises the effective values onto the recipient copy as `secrets.use_only` and `secrets.access_expires_at`, so every client and read path sees them without a join. It runs on share create and change, group share create and change, membership add, change and removal, and in the expiry job. + +For a copy reached through several grants (a direct share and a team folder, or two memberships), the copy is use-only only when every grant is use-only, and the access end is the latest end date, with no end date winning. The most generous grant wins, as it does for grades. + +Alternative considered: computing the flags at read time with joins. Rejected: the extension match, the offline manifest and the CLI list all read owned rows directly; a column keeps each of them one query. + +### D2: The owner sets both in the dialogs; only the owner changes them + +The share dialog and the team-folder dialog get a "Use only (can sign in, cannot view or copy)" checkbox and an optional "Access ends on" date. `use_only` on a membership is accepted only with grade `read`. A date in the past is refused. The owner, and a team-folder manager where `sharing-team-folder-manager-role` applies, can change both later through `PATCH /api/v1/shares/{id}` and the existing membership `PATCH`. The recipient cannot change either: they are not in the recipient-updatable secret fields. + +### D3: What the clients must refuse for a use-only copy + +Web app: no reveal toggle in `PasswordField.vue`, no copy in `CopyButton.vue` for the value and additional fields, no value in the detail sidebar, no edit dialog, no reveal in version history, no value in any export (backup, CXF, CXP transfer, print), and exclusion from bulk export and bulk share. The name, URL and login name stay visible. A current TOTP code may be shown because signing in needs it; the seed is never shown. + +Extension: fill on a site whose registrable domain matches the copy's URL, with no "fill anyway" on a mismatch; fill only into a field of type `password`; never show or copy the value; never offer the save-or-update prompt for that copy; report each fill to `POST /api/v1/secrets/{id}/used`. + +CLI: `show`, `get key` and `copy key` refuse with "This secret is use-only. Sign in through the Keepiq browser extension." `list` shows it with a use-only marker. + +### D4: What the server refuses for a use-only or expiring copy + +- Any share whose source is such a copy: direct, batch, register-batch, group, team-folder fan-out (the copy is excluded from subtree refs), link share, delegation and handover. +- Recipient-side updates and sync of a use-only copy. +- Version history ciphertext of a use-only copy for its recipient. +- The value ciphertext of a use-only copy in the recipient's GDPR export (metadata stays). + +These refusals are real: they hold even against a modified client. + +### D5: Expiry is enforced on every read, then cleaned up + +Every recipient read path adds `access_expires_at IS NULL OR access_expires_at > now`, so a copy stops being served at its end date, not at the next job run. `ExpireSharesJob` (a `TimedJob` every 15 minutes) revokes expired share targets through `ShareRevocationService`, removes expired memberships through the team-folder removal path, and re-runs the resolver for copies still covered by another grant. The offline manifest carries `accessExpiresAt`; the offline client refuses to decrypt a copy past it and drops it at the next sync. + +### D6: Notifications and honesty + +A day before the end date the recipient gets `share_access_ending`. When access ends, the recipient gets `share_access_ended` and the owner gets a notification that says, for a share that was not use-only, "Bob could see this password. Rotate it if Bob should no longer know it", and for a use-only share, "Bob could not view this password in Keepiq". The share dialog shows, next to the use-only checkbox: "Keepiq's apps will not show or copy the password. Someone with technical skill can still read it from their own device. Rotate it when their access ends." + +## Security and zero-knowledge + +Use-only does not change what the server or the recipient's device holds. The recipient's copy is still encrypted to the recipient's certificate, because the recipient's device must decrypt the password to fill it (ADR-003). A recipient who uses a modified client, a debugger, or their own private key against the API can read it. This is the same limit Bitwarden and 1Password document for their equivalent permission. What use-only guarantees is narrower and real: Keepiq's own apps never display or copy the value, the server refuses every onward path it can see (D4), and each fill is recorded. + +Expiry is server-enforced: after the end date the server never serves the copy's ciphertext to the recipient, then deletes it. It cannot remove what the recipient already learned or what an offline snapshot on their device already holds until that device syncs; D5 and D6 handle both honestly. + +Stored in plain: the two flags on share targets, group shares, memberships and copies. They are access metadata, not secrets. Nothing new is stored encrypted. + +## Risks / Trade-offs + +- **Owners may over-trust use-only.** D6 puts the limit in the dialog and in the end-of-access notice. +- **A use-only login on a site with a non-standard login flow** may not fill; the recipient then cannot sign in and asks the owner. Acceptable for the purpose. +- **Clock skew** between the database and PHP. Comparisons use the database time in queries and the job, so one clock decides. +- **The fifteen minute job interval** does not delay the end of access, because reads already filter (D5); it only delays the cleanup. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests create an owner, a recipient, a group and a team folder in the PHPUnit and Playwright setups. + +## Migration + +A new migration step adds `use_only` (boolean, default false) and `expires_at` (datetime, nullable) to `keepiq_share_targets`, `keepiq_group_shares` and `keepiq_team_folder_members`, and `use_only` (boolean, default false) and `access_expires_at` (datetime, nullable, indexed) to `keepiq_secrets`. Existing rows stay unrestricted. The `` in `appinfo/info.xml` must bump. diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/proposal.md b/openspec/changes/sharing-use-only-and-expiring-shares/proposal.md new file mode 100644 index 000000000..d5afd2696 --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/proposal.md @@ -0,0 +1,64 @@ +--- +kind: code +--- + +# Use-only shares and shares that end by themselves + +## Why + +Every Keepiq recipient who can use a shared secret can also see and copy it, and every share lasts until the owner revokes it by hand. Organisations want to let a colleague or a temporary worker sign in to a shared account without handing over the password, and to give access for a fixed period, such as a project or a replacement during leave. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| sharing-24 | Let a colleague sign in with a shared login without being able to see or copy the password. | Every recipient who can use a secret can also reveal it. | +| sharing-25 | Share an item with a colleague for a set period, after which their access ends by itself. | A share to a colleague lasts until it is revoked by hand. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Neither is built. A search for `hidePassword`, `useOnly` or `can_view` in `src` and `lib` finds nothing, and team-folder grades are read or write, both of which reveal (`openspec/specs/folder-permission-grades/spec.md`). `lib/Controller/ShareController.php` has no expiry on a user share; time-bound access exists only for public links (sharing-14) and ownership handover (sharing-11). + +Use-only in a zero-knowledge vault is honest only as a client-enforced control. To fill a password, the recipient's own device must decrypt it. This change says so in the product, specifies exactly what the web app, the browser extension and the CLI must refuse, and adds the server-side refusals that are enforceable (no onward sharing, no recipient edits, no version reveal). Expiry, by contrast, is enforced by the server. + +### Demand + +- sharing-24, tender: https://canadabuys.canada.ca/en/tender-opportunities/25260005 +- sharing-25, changelog: https://github.com/bitwarden/clients/pull/22921 + +### Competitors rated yes + +sharing-24: + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Api/Models/Request/SelectionReadOnlyRequestModel.cs:11 HidePasswords on collection access; bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts:134 ViewExceptPass, :136 EditExceptPass ... The password is still decrypted on the device, so this is a UI control, not a cryptographic one." +- 1Password: "https://support.1password.com/create-share-vaults-teams/ : in 1Password Business a group can use items without revealing or copying passwords when the 'View and Copy Passwords' vault permission is removed; https://support.1password.com/permission-enforcement/ notes this permission is client-enforced." + +sharing-25: + +- Keeper: "https://docs.keeper.io/enterprise-guide/sharing/time-limited-access : share credentials with other Keeper users 'on a temporary basis, automatically revoking access at a specified time'." +- Nextcloud Passwords: "marius-wieschollek/passwords@2026.9.0 src/vue/Components/Sharing/ShareOptionsForm.vue:55 expires date; src/lib/Controller/Api/ShareApiController.php:162 expires; src/lib/Cron/SynchronizeShares.php:133 deleteExpiredShares() Note: Shares take an expiry date and a background job removes them when it passes." + +## What Changes + +- A share to a user or group, and a team-folder membership with grade `read`, can be marked use-only. The recipient's copy carries the flag. +- The web app, the extension and the CLI refuse to show, copy, export or edit the value of a use-only copy. The extension still fills it on the matching site. The share dialog tells the owner plainly that use-only is enforced by Keepiq's own apps, not by cryptography. +- The server refuses any onward sharing from a use-only copy, recipient edits, version reveal for the recipient, and link shares; it records each use the extension reports. +- A share to a user or group, and a team-folder membership, can carry an end date. From that moment the server stops serving the copy, and a background job revokes the share or removes the membership through the existing paths. Recipients are warned a day before; owners are told when access ended, with a rotation hint when the recipient could see the value. +- A copy with an end date cannot be shared onward either, so expiry cannot be escaped. + +## Capabilities + +### New Capabilities + +- `use-only-shares`: use-only flag on shares and memberships, client refusals, server refusals, use recording, and the honest client-enforcement statement. +- `expiring-shares`: end dates on shares and memberships, read-path enforcement, background revocation, notifications, and offline handling. + +### Modified Capabilities + +None. `user-sharing`, `team-folder-sharing` and `folder-permission-grades` keep their requirements; this change adds its own. + +## Impact + +- **Backend**: `ShareController`, `DirectShareRegistrar`, `GroupShareController` and `TeamFolderMemberController` accept `useOnly` and `expiresAt`; a resolver materialises both onto the recipient copy; read paths filter expired copies; share sources refuse flagged copies; an `ExpireSharesJob`; a `POST /api/v1/secrets/{id}/used` route and audit events; notification subjects for ending access. +- **Frontend**: share and team-folder dialogs get the two options; `PasswordField.vue`, `CopyButton.vue`, `SecretDetailSidebar.vue`, the version history, exports and bulk actions respect use-only; the extension popup and worker; the CLI `show`, `get` and `copy`. +- **Database**: new columns on `keepiq_share_targets`, `keepiq_group_shares`, `keepiq_team_folder_members` and `keepiq_secrets`; a migration and a `` bump. +- **Security**: expiry is server-enforced; use-only is client-enforced and documented as such; no key material changes. +- **Cross-app**: none. diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/specs/expiring-shares/spec.md b/openspec/changes/sharing-use-only-and-expiring-shares/specs/expiring-shares/spec.md new file mode 100644 index 000000000..af3d42211 --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/specs/expiring-shares/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: Shares and memberships can carry an end date + +The system MUST let the owner set, change or clear an end date on a user share, a group share and a team-folder membership, and MUST refuse a date in the past. The end date MUST be materialised on each recipient copy as `access_expires_at`; a copy reached through several grants MUST take the latest end date, and a grant without an end date MUST win. The recipient MUST NOT be able to change it. + +#### Scenario: A replacement during leave + +- **GIVEN** a vault owner sharing "Payroll portal" with Carla, who covers during a colleague's leave +- **WHEN** they set "Access ends on" to the colleague's return date +- **THEN** Carla's copy MUST carry that `access_expires_at` + +#### Scenario: A past date is refused + +- **GIVEN** a vault owner in the share dialog +- **WHEN** they submit an end date in the past +- **THEN** the system MUST reject the request with a bad-request response + +### Requirement: The server stops serving an expired copy at its end date + +Every read path that serves a recipient's secrets (the secret list and detail, the extension match, unified search and the offline manifest) MUST exclude a copy whose `access_expires_at` has passed, using the database clock. + +#### Scenario: Access ends on time + +- **GIVEN** Carla's copy with an end date of today at 17:00 +- **WHEN** she opens the secret list at /secrets at 17:01 +- **THEN** "Payroll portal" MUST NOT be listed +- **AND** `GET /api/v1/secrets/{id}` for her copy MUST answer as for an unknown secret + +### Requirement: A background job removes expired access + +A background job running every 15 minutes MUST revoke expired share targets through the existing revocation path, remove expired team-folder memberships through the existing removal path, and recompute the flags of copies still covered by another grant. + +#### Scenario: The copy is deleted after its end + +- **GIVEN** an expired share target +- **WHEN** the job runs +- **THEN** the share target and Carla's copy MUST be deleted + +### Requirement: An expiring copy cannot be shared onward + +The system MUST refuse any share whose source is a copy with an end date, by every path that creates a share, and MUST leave such copies out of team-folder fan-out. + +#### Scenario: Carla cannot extend her own access + +- **GIVEN** Carla's expiring copy +- **WHEN** she tries to share it with her personal account +- **THEN** the system MUST refuse and create no copy + +### Requirement: People are told before and when access ends + +The recipient MUST be notified a day before the end date and when access ends. The owner MUST be notified when access ends; for a share that was not use-only the notice MUST suggest rotating the value because the recipient could see it. + +#### Scenario: Owner gets a rotation hint + +- **GIVEN** a non-use-only share to Carla that just expired +- **WHEN** the job removes it +- **THEN** the owner MUST receive a notification that Carla's access ended and that Carla could see the password + +### Requirement: Offline copies respect the end date + +The offline manifest MUST carry each copy's `accessExpiresAt`, and the offline client MUST refuse to decrypt a copy past it and drop it at the next sync. + +#### Scenario: An offline snapshot past the end date + +- **GIVEN** Carla's offline snapshot holding a copy whose end date has passed +- **WHEN** she unlocks offline and opens it +- **THEN** the web app MUST NOT decrypt it and MUST say her access ended diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/specs/use-only-shares/spec.md b/openspec/changes/sharing-use-only-and-expiring-shares/specs/use-only-shares/spec.md new file mode 100644 index 000000000..e222c6519 --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/specs/use-only-shares/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: Owners can share a secret as use-only + +The system MUST let the owner mark a user share, a group share, or a team-folder membership with grade `read` as use-only, and MUST refuse use-only on a `write` or `manage` membership. The flag MUST be materialised on each recipient copy; a copy reached through several grants MUST be use-only only when every grant is use-only. Only the owner (or a team-folder manager for memberships) MUST be able to change the flag. + +#### Scenario: Owner shares a login as use-only + +- **GIVEN** a vault owner in the share dialog for the secret "Supplier portal" +- **WHEN** they share it with Bob and tick "Use only (can sign in, cannot view or copy)" +- **THEN** Bob's copy MUST carry `useOnly` true + +#### Scenario: A second, unrestricted grant lifts use-only + +- **GIVEN** Bob holds a use-only copy through a direct share +- **WHEN** the same secret reaches Bob through a team folder where he is a `read` member without use-only +- **THEN** Bob's copy MUST carry `useOnly` false + +### Requirement: The share dialog states the limit of use-only + +The share dialog and the team-folder dialog MUST state, next to the use-only option, that Keepiq's apps will not show or copy the value, that someone with technical skill can still read it from their own device, and that the owner should rotate it when access ends. + +#### Scenario: The owner sees the limit before choosing + +- **GIVEN** a vault owner opening the share dialog +- **WHEN** the use-only option is shown +- **THEN** the explanation of its limit MUST be visible next to it + +### Requirement: Keepiq's clients never reveal a use-only value + +For a use-only copy, the web app MUST NOT show or copy the value or additional fields, MUST NOT offer editing or version reveal, and MUST leave the value out of every export and bulk share, while it MAY show the name, URL, login name and a current TOTP code. The browser extension MUST fill it only on a site whose registrable domain matches the copy's URL and only into a password field, MUST NOT show or copy it, MUST NOT offer to save or update it, and MUST report each fill. The CLI MUST refuse `show`, `get key` and `copy key` for it. + +#### Scenario: Web app hides the password + +- **GIVEN** Bob with a use-only copy of "Supplier portal" +- **WHEN** he opens it in the secret list at /secrets +- **THEN** there MUST be no reveal toggle and no copy action for the password +- **AND** the name, URL and login name MUST be shown + +#### Scenario: The extension signs Bob in + +- **GIVEN** Bob with a use-only copy whose URL is `https://portal.supplier.example` +- **WHEN** he chooses it in the extension popup on `portal.supplier.example` +- **THEN** the extension MUST fill the login and password fields and report the fill +- **AND** the popup MUST NOT show or copy the password + +#### Scenario: The extension refuses another site + +- **GIVEN** Bob with the same use-only copy +- **WHEN** he is on `attacker.example.net` +- **THEN** the extension MUST NOT offer or fill the copy + +#### Scenario: The CLI refuses to print it + +- **GIVEN** Bob with a use-only copy +- **WHEN** he runs `keepiq show ` +- **THEN** the CLI MUST refuse and print that the secret is use-only + +### Requirement: The server refuses what it can enforce + +The system MUST refuse any share whose source is a use-only copy (direct, batch, group, team-folder fan-out, link share, delegation and handover), MUST refuse recipient updates and sync of a use-only copy, MUST refuse the recipient's version history ciphertext for it, and MUST leave its value ciphertext out of the recipient's GDPR export. + +#### Scenario: A modified client cannot share it onward + +- **GIVEN** Bob with a use-only copy +- **WHEN** a script with Bob's session calls `POST /api/v1/shares/register-batch` with that copy as source +- **THEN** the system MUST refuse the request and create no copy + +#### Scenario: Bob cannot overwrite it + +- **GIVEN** Bob with a use-only copy +- **WHEN** Bob calls `PUT /api/v1/secrets/{id}/sync` for it +- **THEN** the system MUST refuse the request + +### Requirement: Each use is recorded + +The system MUST provide `POST /api/v1/secrets/{id}/used` for the recipient of a use-only copy, MUST record a `secret.used` audit event with identifiers only, and MUST show it in the owner's activity for the source secret. + +#### Scenario: The owner sees who used the login + +- **GIVEN** Bob filled a use-only copy through the extension +- **WHEN** the owner opens the activity tab of "Supplier portal" +- **THEN** the tab MUST list Bob's use with its time diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/tasks.md b/openspec/changes/sharing-use-only-and-expiring-shares/tasks.md new file mode 100644 index 000000000..d091d95e2 --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/tasks.md @@ -0,0 +1,44 @@ +# Tasks: use-only shares and expiring shares + +## 1. Data and resolver + +- [ ] 1.1 Add the migration step for the new columns on share targets, group shares, team-folder members and secrets, and bump ``. Verify: a PHPUnit migration test asserts the columns and the `access_expires_at` index. +- [ ] 1.2 Add `ShareRestrictionResolver` that materialises `use_only` (all grants use-only) and `access_expires_at` (latest end, none wins) onto each copy, and call it from every share, group share and membership write. Verify: PHPUnit for single grants, mixed grants and removal of the last restricted grant. + +## 2. Setting the flags + +- [ ] 2.1 Accept `useOnly` and `expiresAt` on `ShareController::create`, `createBatch`, `registerBatch` and `GroupShareController::create`, and add `PATCH /api/v1/shares/{id}` for the owner. Verify: PHPUnit refuses a past date and a change by the recipient. +- [ ] 2.2 Accept `useOnly` (with grade `read` only) and `expiresAt` on team-folder member add and `PATCH`. Verify: PHPUnit refuses `useOnly` with `write` or `manage`. +- [ ] 2.3 Add the checkbox, the date and the honest use-only text to the share dialog and the team-folder dialog, with the writing skill. Verify: vitest renders both options and the text. + +## 3. Server refusals and use recording + +- [ ] 3.1 Refuse every share source that is a use-only or expiring copy (direct, batch, register-batch, group, link share, delegation, handover) and exclude such copies from team-folder subtree refs. Verify: PHPUnit for each path. +- [ ] 3.2 Refuse recipient updates and sync of a use-only copy, its version history ciphertext for the recipient, and its value ciphertext in the recipient's GDPR export. Verify: PHPUnit for each refusal. +- [ ] 3.3 Add `POST /api/v1/secrets/{id}/used` (recipient of a use-only copy only) with a whitelisted `secret.used` audit event visible in the owner's activity tab. Verify: PHPUnit for the route guard and the audit metadata. + +## 4. Client refusals + +- [ ] 4.1 Web app: hide reveal and copy of the value and additional fields, the edit dialog and version reveal for use-only copies, and exclude them from exports and bulk share. Verify: vitest for `PasswordField.vue`, `CopyButton.vue`, `SecretDetailSidebar.vue`, `VersionHistoryPanel.vue` and the export builder. +- [ ] 4.2 Extension: strict site match, password-field-only fill, no display or copy, no save prompt, and a `used` report per fill. Verify: vitest in `tests/extension/` for each rule. +- [ ] 4.3 CLI: refuse `show`, `get key` and `copy key` for use-only copies and mark them in `list`. Verify: a Go test with a use-only row. + +## 5. Expiry + +- [ ] 5.1 Filter expired copies on every recipient read path (list, get, extension match, unified search, offline manifest). Verify: PHPUnit asserts an expired copy is not returned by any of them one second after its end. +- [ ] 5.2 Add `ExpireSharesJob` (every 15 minutes) that revokes expired targets, removes expired memberships and re-runs the resolver. Verify: PHPUnit for a direct share, a group-derived share and a membership. +- [ ] 5.3 Add the `share_access_ending` and `share_access_ended` notifications and the owner's end-of-access notice with the rotation hint. Verify: PHPUnit for the subjects and the two owner texts. +- [ ] 5.4 Make the offline client refuse to decrypt a copy past `accessExpiresAt`. Verify: vitest with a snapshot holding an expired copy. + +## 6. End to end + +- [ ] 6.1 Add a Playwright flow: the owner shares a login use-only with a one-day end; the recipient sees no reveal or copy in the web app; after the end date (clock moved in the test) the secret is gone from the recipient's list. Verify: the Playwright spec passes in the E2E job. + +## Acceptance criteria + +- An owner can mark a user share, group share or read-grade team-folder membership as use-only, and give any of them an end date. +- Keepiq's web app, extension and CLI never show, copy, export or edit the value of a use-only copy; the extension still fills it on the matching site. +- The share dialog states that use-only is enforced by Keepiq's apps and can be bypassed by a technically skilled recipient. +- The server refuses every onward share of a use-only or expiring copy, recipient edits of a use-only copy, and its version reveal. +- From its end date the server serves an expired copy on no read path, and the job removes it within 15 minutes. +- The owner is told when access ended, with a rotation hint when the recipient could see the value. From 308838a3bed2384965602df9d437f3dc5e161522 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:31:54 +0200 Subject: [PATCH 3/3] docs(parity): specify the 14 rows of batch 3 and correct clients-01 audit-14, clients-01, clients-04, clients-06, clients-13, clients-19, clients-21, crypto-09, crypto-11, crypto-24, sharing-17, sharing-18, sharing-24 and sharing-25 are specified by the ten changes of this batch. clients-01: matching already covers shared and team folder secrets, because every recipient copy is a row owned by the recipient (RecipientSecretCopyService.php:112-113) and the extension match is owner-scoped (ExtensionController.php:195). The note and the decision reason now say so; the missing half is the store release. --- openspec/parity/capabilities.json | 58 +++++++++++++++--------------- openspec/parity/gap-decisions.json | 2 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 78a620bc2..f65bf716f 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -1721,8 +1721,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "partial", "built": { - "state": "built", - "evidence": "src/components/PasskeyManager.vue:25 offers Touch ID, Windows Hello or a security key; src/store/modules/passkey.js:221 userVerification 'preferred' with the PRF extension; browser-extension/src/popup/popup.js unlocks with the master password only (grep -rni 'biometric\\|webauthn' browser-extension/src/popup: no hits)", + "state": "specified", + "evidence": "Specified in openspec/changes/clients-extension-unlock-lock-and-accounts on 2026-09-27 for the missing half: fingerprint or face unlock in the browser extension; the web app unlock with a platform passkey is built. Before: src/components/PasskeyManager.vue:25 offers Touch ID, Windows Hello or a security key; src/store/modules/passkey.js:221 userVerification 'preferred' with the PRF extension; browser-extension/src/popup/popup.js unlocks with the master password only (grep -rni 'biometric\\|webauthn' browser-extension/src/popup: no hits)", "owner": "ConductionNL/keepiq", "reachedOn": "Lock page (/lock) -> Unlock with passkey, using a platform authenticator", "note": "Fingerprint or face unlock works in the web app only by enrolling a platform passkey (Touch ID, Windows Hello) whose browser supports PRF. The browser extension and CLI have no biometric unlock." @@ -1781,8 +1781,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "src/components/settings/AdminSuiteSection.vue:21,180 admin can only force-revoke a suite so the user starts a new empty vault; grep -rni 'escrow\\|recovery key\\|account recovery' lib: only emergency-access envelopes escrowed to a contact's certificate, never to an admin", + "state": "specified", + "evidence": "Specified in openspec/changes/crypto-organisation-account-recovery on 2026-09-27. Before: src/components/settings/AdminSuiteSection.vue:21,180 admin can only force-revoke a suite so the user starts a new empty vault; grep -rni 'escrow\\|recovery key\\|account recovery' lib: only emergency-access envelopes escrowed to a contact's certificate, never to an admin", "owner": "ConductionNL/keepiq", "note": "By zero-knowledge design an administrator cannot restore access to a user's secrets. The admin can force-revoke the locked suite so the user can set up a fresh vault, but the old secrets stay unreadable unless the user has an emergency contact or a backup file." }, @@ -2193,8 +2193,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "yes", "built": { - "state": "none", - "evidence": "searched 'device approval', 'auth request', 'approveLogin', 'LoginRequest' in lib/ src/ appinfo/routes.php: no match; the vault unlocks with the user's own passphrase on each device", + "state": "specified", + "evidence": "Specified in openspec/changes/crypto-new-device-approval on 2026-09-27. Before: searched 'device approval', 'auth request', 'approveLogin', 'LoginRequest' in lib/ src/ appinfo/routes.php: no match; the vault unlocks with the user's own passphrase on each device", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", "note": "Sign-in is Nextcloud's; a new device unlocks the vault with the user's passphrase, and there is no approve-from-another-device or admin approval flow." @@ -2761,8 +2761,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "grep -rn 'federat\\|remote share\\|outside.*organi[sz]ation' lib/Controller lib/Service: no sharing-related hits (the one federated-cloud-ID hit in EncryptionSuiteProvisioningService.php:331 is only used to name a certificate's common name, not to share with an external account). All sharing paths (user-to-user, team folder, link, send) target a Nextcloud user ID, group ID, or an anonymous public link on this instance.", + "state": "specified", + "evidence": "Specified in openspec/changes/sharing-federated-recipients on 2026-09-27. Before: grep -rn 'federat\\|remote share\\|outside.*organi[sz]ation' lib/Controller lib/Service: no sharing-related hits (the one federated-cloud-ID hit in EncryptionSuiteProvisioningService.php:331 is only used to name a certificate's common name, not to share with an external account). All sharing paths (user-to-user, team folder, link, send) target a Nextcloud user ID, group ID, or an anonymous public link on this instance.", "owner": "ConductionNL/keepiq", "note": "Sharing with an account on a different Nextcloud instance is not supported; a password-protected public link (sharing-14) is the closest substitute." }, @@ -2790,8 +2790,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "The only role-like concept in the codebase is a team folder membership grade of read or write (folder-permission-grades spec, sharing-09); there is no manager/viewer role vocabulary and no 'collection' concept beyond team folders. grep -rln 'manager\\|viewer' src/store/modules lib/Controller for role-purposed hits: none.", + "state": "specified", + "evidence": "Specified in openspec/changes/sharing-team-folder-manager-role on 2026-09-27. Before: The only role-like concept in the codebase is a team folder membership grade of read or write (folder-permission-grades spec, sharing-09); there is no manager/viewer role vocabulary and no 'collection' concept beyond team folders. grep -rln 'manager\\|viewer' src/store/modules lib/Controller for role-purposed hits: none.", "owner": "ConductionNL/keepiq" }, "rowSource": "competitor", @@ -2969,8 +2969,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "git grep -i 'hidePassword|useOnly|can_view' src lib: no match; team folder roles are read-only or edit (sharing-09), both reveal the value", + "state": "specified", + "evidence": "Specified in openspec/changes/sharing-use-only-and-expiring-shares on 2026-09-27. Before: git grep -i 'hidePassword|useOnly|can_view' src lib: no match; team folder roles are read-only or edit (sharing-09), both reveal the value", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", "note": "Every recipient who can use a secret can also reveal it." @@ -3001,8 +3001,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "yes", "built": { - "state": "none", - "evidence": "lib/Controller/ShareController.php: no expiry field on a user share (git grep -i expir lib/Controller/ShareController.php: no match); time-bound access exists only for public links (sharing-14) and ownership handover (sharing-11)", + "state": "specified", + "evidence": "Specified in openspec/changes/sharing-use-only-and-expiring-shares on 2026-09-27. Before: lib/Controller/ShareController.php: no expiry field on a user share (git grep -i expir lib/Controller/ShareController.php: no match); time-bound access exists only for public links (sharing-14) and ownership handover (sharing-11)", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", "note": "A share to a colleague lasts until it is revoked by hand." @@ -6703,8 +6703,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "no", "built": { - "state": "built", - "evidence": "lib/Service/SiemTransport.php:100 generic RFC 5424 syslog and :152 generic HTTPS JSON webhook; grep -i 'splunk|sentinel|datadog|CEF|LEEF' lib src/components/settings/SiemSection.vue: no hits", + "state": "specified", + "evidence": "Specified in openspec/changes/audit-siem-vendor-connectors on 2026-09-27 for the missing half: named Splunk, Microsoft Sentinel and CEF presets on the SIEM export; the generic syslog and webhook stream is built. Before: lib/Service/SiemTransport.php:100 generic RFC 5424 syslog and :152 generic HTTPS JSON webhook; grep -i 'splunk|sentinel|datadog|CEF|LEEF' lib src/components/settings/SiemSection.vue: no hits", "owner": "ConductionNL/keepiq", "reachedOn": "Nextcloud admin settings > Keepiq > SIEM section", "note": "No named connectors or vendor formats; Splunk, Sentinel and similar tools can ingest the generic syslog or webhook stream, but the admin has to configure the receiving side." @@ -7239,11 +7239,11 @@ "hashicorp-vault": "no", "nextcloud-passwords": "yes", "built": { - "state": "built", - "evidence": "browser-extension/src/popup/popup.js:45 send('match') -> browser-extension/src/background/service-worker.js:91 doMatch -> browser-extension/src/lib/api.js:97 GET /api/v1/extension/match -> appinfo/routes.php:407 -> lib/Controller/ExtensionController.php:180 (owner's secrets by registrable domain) ; click -> service-worker.js:113 doFill (decrypt in worker) -> browser-extension/src/content/content-script.js:95 fillCredential", + "state": "specified", + "evidence": "Specified in openspec/changes/clients-extension-store-release on 2026-09-27 for the missing half: a packaged, signed extension in the browser stores. Matrix corrected: matching already covers shared and team folder secrets, because every recipient copy is a row owned by the recipient (lib/Service/RecipientSecretCopyService.php:112-113, lib/Service/TeamFolderShareService.php:295), so the owner-scoped match (lib/Controller/ExtensionController.php:195) returns them. Before: browser-extension/src/popup/popup.js:45 send('match') -> browser-extension/src/background/service-worker.js:91 doMatch -> browser-extension/src/lib/api.js:97 GET /api/v1/extension/match -> appinfo/routes.php:407 -> lib/Controller/ExtensionController.php:180 (owner's secrets by registrable domain) ; click -> service-worker.js:113 doFill (decrypt in worker) -> browser-extension/src/content/content-script.js:95 fillCredential", "owner": "ConductionNL/keepiq", "reachedOn": "browser-extension popup (browser-extension/src/popup/popup.js) -> matched candidate list -> click to fill", - "note": "Autofill works in code: the popup lists owned secrets matching the site, decrypts in the worker and fills on click. The extension is not packaged or published, and matching only covers secrets the user owns, not shared or team-folder secrets.", + "note": "Autofill works in code: the popup lists owned secrets matching the site, decrypts in the worker and fills on click. The extension is not packaged or published, and matching only covers secrets the user owns, not shared or team-folder secrets. Corrected 2026-09-27: matching does cover shared and team folder secrets, because every recipient copy is a row owned by the recipient (lib/Service/RecipientSecretCopyService.php:112-113, lib/Service/TeamFolderShareService.php:295) and the match is owner-scoped (lib/Controller/ExtensionController.php:195). The missing half is the store release.", "defects": [ { "at": "browser-extension/build.mjs:1", @@ -7382,8 +7382,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "no", "built": { - "state": "built", - "evidence": "browser-extension/src/background/service-worker.js:137 totpCodeForHost on fill -> :155 doTotpForHost (totp-typed secret matched by host, seed decrypted in worker, computeTotp browser-extension/src/lib/totp-service.js) -> :141 'fill-otp' -> content-script.js:115 fillOtp ; popup.js:66 copyWithAutoClear and popup.js:106 live code", + "state": "specified", + "evidence": "Specified in openspec/changes/clients-extension-store-release on 2026-09-27 for the missing half: filling a one-time code field that appears after the login step; filling a code field present at fill time is built. Before: browser-extension/src/background/service-worker.js:137 totpCodeForHost on fill -> :155 doTotpForHost (totp-typed secret matched by host, seed decrypted in worker, computeTotp browser-extension/src/lib/totp-service.js) -> :141 'fill-otp' -> content-script.js:115 fillOtp ; popup.js:66 copyWithAutoClear and popup.js:106 live code", "owner": "ConductionNL/keepiq", "reachedOn": "browser-extension popup (browser-extension/src/popup/popup.js) -> fill a login; the matching authenticator code is filled into an OTP field and copied", "note": "After filling a login the worker looks for a totp-typed secret on the same host, fills a detected one-time-code field and copies the code with a 30 second clipboard clear. The OTP field has to be on the page at fill time, and the extension is not distributed.", @@ -7474,8 +7474,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "unknown", "built": { - "state": "built", - "evidence": "browser-extension/src/background/service-worker.js:37 touchActivity -> browser-extension/src/lib/vault.js:114 armIdleLock (default 15 minutes, service-worker.js:26) ; service-worker.js:254 chrome.idle locked -> vault.lock ; popup.js:197 manual Lock ; worker termination drops the in-memory key", + "state": "specified", + "evidence": "Specified in openspec/changes/clients-extension-unlock-lock-and-accounts on 2026-09-27 for the missing half: a user-chosen idle period; the fixed 15 minute idle lock is built. Before: browser-extension/src/background/service-worker.js:37 touchActivity -> browser-extension/src/lib/vault.js:114 armIdleLock (default 15 minutes, service-worker.js:26) ; service-worker.js:254 chrome.idle locked -> vault.lock ; popup.js:197 manual Lock ; worker termination drops the in-memory key", "owner": "ConductionNL/keepiq", "reachedOn": "browser-extension popup (browser-extension/src/popup/popup.js) (automatic, plus the Lock button)", "note": "The extension locks after 15 idle minutes, on OS or browser lock, on worker termination and on demand. The idle period cannot be changed because nothing ever writes config.idleMinutes, and the extension is not distributed.", @@ -7729,8 +7729,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "grep -ril 'ssh-agent|ssh agent' lib src cli browser-extension: no hits; ssh_key exists only as a stored secret type (src/cxf/cxf.js:355)", + "state": "specified", + "evidence": "Specified in openspec/changes/clients-ssh-agent on 2026-09-27. Before: grep -ril 'ssh-agent|ssh agent' lib src cli browser-extension: no hits; ssh_key exists only as a stored secret type (src/cxf/cxf.js:355)", "owner": "ConductionNL/keepiq", "reachedOn": "none", "note": "SSH keys can be stored as secrets, but nothing exposes them to an SSH agent." @@ -7949,8 +7949,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "unknown", "built": { - "state": "none", - "evidence": "offline cache is read-only by design (clients-09; src/App.vue:73 stale-data banner, src/offline)", + "state": "specified", + "evidence": "Specified in openspec/changes/clients-offline-edits on 2026-09-27. Before: offline cache is read-only by design (clients-09; src/App.vue:73 stale-data banner, src/offline)", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", "note": "Offline mode reads only; edits need the server." @@ -8013,8 +8013,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "unknown", "built": { - "state": "none", - "evidence": "browser-extension/src/lib/api.js:15-31 stores one paired config under CONFIG_KEY; no account list", + "state": "specified", + "evidence": "Specified in openspec/changes/clients-extension-unlock-lock-and-accounts on 2026-09-27. Before: browser-extension/src/lib/api.js:15-31 stores one paired config under CONFIG_KEY; no account list", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", "note": "The extension pairs with one Nextcloud account at a time; switching means unpairing." diff --git a/openspec/parity/gap-decisions.json b/openspec/parity/gap-decisions.json index 9d537d8bc..4dfc50840 100644 --- a/openspec/parity/gap-decisions.json +++ b/openspec/parity/gap-decisions.json @@ -235,7 +235,7 @@ "row": "clients-01", "matrix": "keepiq", "decision": "build", - "reason": "Build: 5 competitors rate yes (Bitwarden, 1Password, Passbolt, Keeper, Nextcloud Passwords). Specified for the missing half: a packaged, signed extension in the browser stores, and matching over shared and team folder secrets; autofill of owned secrets is built.", + "reason": "Build: 5 competitors rate yes (Bitwarden, 1Password, Passbolt, Keeper, Nextcloud Passwords). Specified for the missing half: a packaged, signed extension in the browser stores. Matrix corrected: matching already covers shared and team folder secrets, because every recipient copy is a row owned by the recipient (lib/Service/RecipientSecretCopyService.php:112-113, lib/Service/TeamFolderShareService.php:295), so the owner-scoped match (lib/Controller/ExtensionController.php:195) returns them.", "change": "clients-extension-store-release", "decidedOn": "2026-09-27" },