From c7a505608718278b85df8b93e63137cf016d6a99 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 21:14:04 +0200 Subject: [PATCH 001/245] docs(openspec): decide keepiq's owned parity rows, nine new changes and 29 decisions (#837) --- .../admin-secret-type-editor/design.md | 31 +++ .../admin-secret-type-editor/proposal.md | 48 ++++ .../specs/admin-secret-types/spec.md | 33 +++ .../changes/admin-secret-type-editor/tasks.md | 20 ++ .../design.md | 28 ++ .../proposal.md | 50 ++++ .../specs/clients-browser-builds/spec.md | 23 ++ .../tasks.md | 15 ++ .../design.md | 34 +++ .../proposal.md | 59 ++++ .../specs/clients-passkey-origin/spec.md | 17 ++ .../specs/clients-save-prompt/spec.md | 23 ++ .../tasks.md | 16 ++ .../design.md | 35 +++ .../proposal.md | 57 ++++ .../specs/vault-session-lock/spec.md | 39 +++ .../tasks.md | 15 ++ .../design.md | 29 ++ .../proposal.md | 49 ++++ .../specs/portability-cxp/spec.md | 33 +++ .../tasks.md | 16 ++ .../design.md | 29 ++ .../proposal.md | 48 ++++ .../specs/portability-import-mapping/spec.md | 27 ++ .../portability-import-field-mapping/tasks.md | 13 + .../sharing-group-share-entry-point/design.md | 30 +++ .../proposal.md | 52 ++++ .../specs/sharing-group/spec.md | 23 ++ .../sharing-group-share-entry-point/tasks.md | 16 ++ .../design.md | 36 +++ .../proposal.md | 60 +++++ .../specs/vault-custom-fields/spec.md | 27 ++ .../specs/vault-ssh-key-item/spec.md | 23 ++ .../tasks.md | 19 ++ .../design.md | 31 +++ .../proposal.md | 52 ++++ .../specs/vault-defaults/spec.md | 23 ++ .../specs/vault-recently-used/spec.md | 23 ++ .../tasks.md | 17 ++ openspec/parity/capabilities.json | 254 +++++++++++------- openspec/parity/gap-decisions.json | 232 ++++++++++++++++ 41 files changed, 1609 insertions(+), 96 deletions(-) create mode 100644 openspec/changes/admin-secret-type-editor/design.md create mode 100644 openspec/changes/admin-secret-type-editor/proposal.md create mode 100644 openspec/changes/admin-secret-type-editor/specs/admin-secret-types/spec.md create mode 100644 openspec/changes/admin-secret-type-editor/tasks.md create mode 100644 openspec/changes/clients-extension-firefox-and-safari-builds/design.md create mode 100644 openspec/changes/clients-extension-firefox-and-safari-builds/proposal.md create mode 100644 openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md create mode 100644 openspec/changes/clients-extension-firefox-and-safari-builds/tasks.md create mode 100644 openspec/changes/clients-extension-save-prompt-and-passkey-origin/design.md create mode 100644 openspec/changes/clients-extension-save-prompt-and-passkey-origin/proposal.md create mode 100644 openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md create mode 100644 openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md create mode 100644 openspec/changes/clients-extension-save-prompt-and-passkey-origin/tasks.md create mode 100644 openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md create mode 100644 openspec/changes/crypto-session-timeout-and-inactivity-lock/proposal.md create mode 100644 openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md create mode 100644 openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md create mode 100644 openspec/changes/portability-cxp-cross-provider-transfer/design.md create mode 100644 openspec/changes/portability-cxp-cross-provider-transfer/proposal.md create mode 100644 openspec/changes/portability-cxp-cross-provider-transfer/specs/portability-cxp/spec.md create mode 100644 openspec/changes/portability-cxp-cross-provider-transfer/tasks.md create mode 100644 openspec/changes/portability-import-field-mapping/design.md create mode 100644 openspec/changes/portability-import-field-mapping/proposal.md create mode 100644 openspec/changes/portability-import-field-mapping/specs/portability-import-mapping/spec.md create mode 100644 openspec/changes/portability-import-field-mapping/tasks.md create mode 100644 openspec/changes/sharing-group-share-entry-point/design.md create mode 100644 openspec/changes/sharing-group-share-entry-point/proposal.md create mode 100644 openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md create mode 100644 openspec/changes/sharing-group-share-entry-point/tasks.md create mode 100644 openspec/changes/vault-custom-field-kinds-and-ssh-key/design.md create mode 100644 openspec/changes/vault-custom-field-kinds-and-ssh-key/proposal.md create mode 100644 openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-custom-fields/spec.md create mode 100644 openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-ssh-key-item/spec.md create mode 100644 openspec/changes/vault-custom-field-kinds-and-ssh-key/tasks.md create mode 100644 openspec/changes/vault-defaults-and-recently-used-widget/design.md create mode 100644 openspec/changes/vault-defaults-and-recently-used-widget/proposal.md create mode 100644 openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md create mode 100644 openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md create mode 100644 openspec/changes/vault-defaults-and-recently-used-widget/tasks.md diff --git a/openspec/changes/admin-secret-type-editor/design.md b/openspec/changes/admin-secret-type-editor/design.md new file mode 100644 index 000000000..627a434cf --- /dev/null +++ b/openspec/changes/admin-secret-type-editor/design.md @@ -0,0 +1,31 @@ +# Design: an administrator defines item types and the fields they carry + +## Context + +At development `156cd800`: + +- `lib/Db/SecretType.php` has `name`, `label`, `scope`, `ownerId`, `createdAt` and no fields. +- `lib/Controller/SecretTypeController.php:99` `create` passes `$isAdmin` to `typeService->createType`; global scope needs the admin role. +- `src/store/modules/secretType.js:73-110` has `createType`, `updateType`, `deleteType` and no caller for the first. +- `lib/Repair/SeedSecretTypes.php` seeds the built-in types; built-ins have no field list and keep their current forms. +- `src/dialogs/SecretCreateDialog.vue` picks the form from the type name. + +## Goals / Non-Goals + +**Goals** +- An administrator can define a type and its fields without code. + +**Non-Goals** +- Per-role publishing of a type. +- Field validation patterns. +- User-scope custom types beyond what the API already allows. + +## Decisions + +### D1: Fields are metadata, values are ciphertext + +The definition (labels and kinds) is not secret and is stored in plain text on the type, like folder names. Values go into the existing encrypted blob, so the server never sees them. + +### D2: Built-in types keep their forms + +Only types with a non-empty `fields` list render the generic typed form, so nothing changes for login, note or SSH key. diff --git a/openspec/changes/admin-secret-type-editor/proposal.md b/openspec/changes/admin-secret-type-editor/proposal.md new file mode 100644 index 000000000..6cde7ca68 --- /dev/null +++ b/openspec/changes/admin-secret-type-editor/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +--- + +# An administrator defines item types and the fields they carry + +## Why + +Custom secret types exist in the API (`appinfo/routes.php:74-77`) and in the store (`src/store/modules/secretType.js:73` `createType`), but no page lets anyone create one, and a type is only `name`, `label`, `scope` and `ownerId` (`lib/Db/SecretType.php`): it cannot say which fields an item of that type carries. The row has a feature request from the Passbolt community and Keeper rates yes (a record template with labelled and required fields, published to roles). A request plus one competitor yes is a build under the decision rule. + +One row, one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `admin-18` | An administrator defines new item types and the fields they carry. | `no`: `no`: the secret-type routes and store exist, no page creates a type, and a type has no field definition at all | + +### Demand + +- `admin-18`: featureRequest, https://community.passbolt.com/t/as-an-administrator-i-can-create-new-secret-types-and-define-their-associated-input-fields/19 + +### Competitors rated yes + +- `admin-18`, keeper: "https://docs.keeper.io/enterprise-guide/creating-new-record-types : an admin with 'Manage Record Types in Vault' creates a record template with labelled and required fields and publishes it to roles." + +## What Changes + +- Add a `fields` definition to a secret type: an ordered list of `{key, label, kind, required}` with kinds `text`, `hidden`, `url`, `email`. +- Add an admin page, Item types, to create, relabel, edit fields and delete global types. +- Make the create and edit dialogs render the fields of the chosen type, storing values in the encrypted additional fields blob under the field label. + +## Capabilities + +### New Capabilities + +- `admin-secret-types` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Database**: one migration adds a `fields` JSON text column to the secret types table; `` bump. +- **Backend**: `SecretType` entity, `SecretTypeService::createType` and `updateType` validate the field list. +- **Frontend**: new admin view and route, changes in `SecretCreateDialog.vue` and `SecretEditDialog.vue`. +- **Cross-row**: reuses the hidden field kind from `vault-custom-field-kinds-and-ssh-key`; the two land in either order because a `hidden` kind falls back to text until that change lands. diff --git a/openspec/changes/admin-secret-type-editor/specs/admin-secret-types/spec.md b/openspec/changes/admin-secret-type-editor/specs/admin-secret-types/spec.md new file mode 100644 index 000000000..93ee59974 --- /dev/null +++ b/openspec/changes/admin-secret-type-editor/specs/admin-secret-types/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Item type definitions + +The system MUST let an administrator create a global item type with a name, a label and an ordered list of fields, each with a label, a kind and a required flag, and MUST let the administrator edit and delete it. A type definition MUST be visible to every user as a choice in the create dialog. Deleting a type MUST leave its secrets readable and move them to the login type as the type service already does. + +#### Scenario: An administrator creates a type + +- **GIVEN** an administrator on the Item types admin page +- **WHEN** the administrator creates Server access with fields Host (url, required), Port (text) and Root password (hidden) and saves +- **THEN** the type appears in every user's create dialog + +#### Scenario: A user fills a typed item + +- **GIVEN** a user choosing Server access in the create dialog +- **WHEN** the user leaves Host empty and saves +- **THEN** the dialog blocks the save and marks Host as required + +#### Scenario: A regular user cannot define a global type + +- **GIVEN** a user without the admin role +- **WHEN** the user posts a global type to the secret types route +- **THEN** the server answers 403 + +### Requirement: Typed fields storage + +Values entered for the fields of a type MUST be stored inside the encrypted additional fields blob, and the server MUST NOT receive them in plain text. + +#### Scenario: Values stay encrypted + +- **GIVEN** a user saving a Server access item +- **WHEN** the request is sent to the server +- **THEN** the request body holds only ciphertext for the field values diff --git a/openspec/changes/admin-secret-type-editor/tasks.md b/openspec/changes/admin-secret-type-editor/tasks.md new file mode 100644 index 000000000..3812f371c --- /dev/null +++ b/openspec/changes/admin-secret-type-editor/tasks.md @@ -0,0 +1,20 @@ +# Tasks: an administrator defines item types and the fields they carry + +## 1. Data and API + +- [ ] 1.1 Add the migration for `fields` and extend the entity and `SecretTypeService` validation (unique keys, at most 30 fields, known kinds). Verify: PHPUnit for valid, duplicate key and unknown kind. +- [ ] 1.2 Accept and return `fields` on the create and update routes. Verify: PHPUnit on the controller; hydra route-auth and semantic-auth gates. + +## 2. Admin page + +- [ ] 2.1 Build the Item types admin view and route with a field list editor, in its own dialog files. Verify: vitest on the editor; Playwright flow create a type as admin. + +## 3. Typed form + +- [ ] 3.1 Render a generic typed form in the create and edit dialogs for types with fields and store values in the encrypted blob. Verify: vitest for required and hidden; Playwright flow create and open a typed item. + +## 4. Close out + +- [ ] 4.1 Add strings to every shipped locale. Verify: `npm run test:l10n`. +- [ ] 4.2 Set row `admin-18` to built and archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/clients-extension-firefox-and-safari-builds/design.md b/openspec/changes/clients-extension-firefox-and-safari-builds/design.md new file mode 100644 index 000000000..85773d376 --- /dev/null +++ b/openspec/changes/clients-extension-firefox-and-safari-builds/design.md @@ -0,0 +1,28 @@ +# Design: a Firefox build that loads, and a Safari build + +## Context + +At development `156cd800`: + +- `browser-extension/manifest.json:3` `manifest_version` 3; `:22` declares only `background.service_worker`. +- `browser-extension/build.mjs:24` builds one bundle for `chrome110` and `firefox110` targets. +- `.github/workflows` holds `cli-release.yml` only; the extension has no CI build. + +## Goals / Non-Goals + +**Goals** +- Each browser gets a package it can load. + +**Non-Goals** +- Store listings and signing (`clients-extension-store-release`). +- Opera and other Chromium forks beyond what the Chromium package covers. + +## Decisions + +### D1: Templates over one manifest + +A base manifest plus a small per-browser overlay keeps one source of truth and makes the differences reviewable. + +### D2: Safari is a separate task that may block + +It needs macOS. It is last, so a missing runner cannot hold up the Firefox fix. diff --git a/openspec/changes/clients-extension-firefox-and-safari-builds/proposal.md b/openspec/changes/clients-extension-firefox-and-safari-builds/proposal.md new file mode 100644 index 000000000..1c589c39a --- /dev/null +++ b/openspec/changes/clients-extension-firefox-and-safari-builds/proposal.md @@ -0,0 +1,50 @@ +--- +kind: code +--- + +# A Firefox build that loads, and a Safari build + +## Why + +The extension has one `browser-extension/manifest.json` with `background.service_worker` (`:22`) and `build.mjs:24` targets `chrome110` and `firefox110`. Firefox MV3 needs `background.scripts`, so the Firefox build probably fails to start; there is no Safari project. Four competitors rate yes. Store publication is covered by `clients-extension-store-release`; this change makes the builds themselves right. + +One row, one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `clients-12` | Use browser extensions for Chrome, Firefox, Edge and Safari. | `partial`: `partial`: one MV3 manifest declares only a service worker, so Firefox probably does not start it; there is no Safari build; only source installs exist | + +### Demand + +- `clients-12`: no demand row. + +### Competitors rated yes + +- `clients-12`, bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/package.json:7 build:chrome, plus build:firefox, build:edge, build:opera, build:safari; apps/browser/src/manifest.json, manifest.v3.json Note: One extension codebase built for Chrome, F" +- `clients-12`, onepassword: "https://releases.1password.com/b5x/stable/ : Chrome, Edge, Brave, Firefox and Safari extensions" +- `clients-12`, passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/chrome, src/chrome-mv3, src/firefox, src/safari build targets (Edge uses the Chromium build) Note: The extension is built for Chrome and other Chromium browsers including Edge, Firef" +- `clients-12`, keeper: "https://docs.keeper.io/user-guides/browser-extensions : KeeperFill for Chrome, Firefox, Safari, Microsoft Edge, Opera and other Chromium browsers" + +## What Changes + +- Generate a per-browser manifest in `build.mjs`: `service_worker` for Chromium, `scripts` plus `browser_specific_settings.gecko` for Firefox. +- Add a Safari web extension conversion step and document it, run on a macOS runner. +- Add a CI job that builds all three and loads Chromium and Firefox headless to check the background starts. + +## Capabilities + +### New Capabilities + +- `clients-browser-builds` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Extension**: `browser-extension/build.mjs`, manifest templates, CI workflow. +- **Backend**: none. +- **Risk**: the Safari step needs macOS and an Apple developer identity to sign; producing the unsigned project is in scope, signing and store release are `clients-extension-store-release`. If no macOS runner is available the Safari task stays open and is reported, and the other tasks still ship. diff --git a/openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md b/openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md new file mode 100644 index 000000000..830d2c34e --- /dev/null +++ b/openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: One build per browser + +The build MUST produce a loadable extension package for Chromium browsers (Chrome, Edge), for Firefox and for Safari, each with the manifest keys that browser requires. The Firefox package MUST start its background script, and the Chromium package MUST keep its service worker. + +#### Scenario: Firefox starts the background + +- **GIVEN** the Firefox package installed as a temporary add-on +- **WHEN** the browser loads it +- **THEN** the background script runs and the popup can reach it + +#### Scenario: Chromium is unchanged + +- **GIVEN** the Chromium package loaded unpacked +- **WHEN** the browser loads it +- **THEN** the service worker registers as before + +#### Scenario: Safari package is produced + +- **GIVEN** a macOS build runner +- **WHEN** the Safari conversion step runs +- **THEN** an Xcode project or app extension bundle is produced and its build log is kept diff --git a/openspec/changes/clients-extension-firefox-and-safari-builds/tasks.md b/openspec/changes/clients-extension-firefox-and-safari-builds/tasks.md new file mode 100644 index 000000000..b0961b6aa --- /dev/null +++ b/openspec/changes/clients-extension-firefox-and-safari-builds/tasks.md @@ -0,0 +1,15 @@ +# Tasks: a Firefox build that loads, and a Safari build + +## 1. Manifests and builds + +- [ ] 1.1 Split the manifest into a base and Chromium and Firefox overlays in `build.mjs` and emit `dist/chromium` and `dist/firefox`. Verify: node test that each manifest has the keys its browser requires. +- [ ] 1.2 Add a Safari conversion step and notes for a macOS runner. Verify: build log from a macOS runner, or report it blocked. + +## 2. CI + +- [ ] 2.1 Add a workflow job that builds all packages and loads Chromium and Firefox headless to check the background starts. Verify: the workflow run. + +## 3. Close out + +- [ ] 3.1 Set row `clients-12` to built (or `building` with the Safari task named) and archive when all tasks are done. Verify: parity_verify --strict. + diff --git a/openspec/changes/clients-extension-save-prompt-and-passkey-origin/design.md b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/design.md new file mode 100644 index 000000000..bcf0207ae --- /dev/null +++ b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/design.md @@ -0,0 +1,34 @@ +# Design: offer to save or update a login at once, and pin passkey requests to the page origin + +## Context + +At development `156cd800`: + +- `browser-extension/src/content/content-script.js:134` `attachSubmitCapture`, `:150` `captureCurrent` (no id), `:192` `injectShim`, `:204-212` relay forwards `data.origin`. +- `browser-extension/src/background/service-worker.js:186` `doSaveCapture`, `:198` updates only with `payload.id`, `:230` passkey routes, `:240` `pendingCapture` in memory. +- `browser-extension/src/popup/popup.js:79` shows the `pending-capture` prompt only in the popup. +- `browser-extension/src/passkey/orchestrator.js:74` `handleCreate` and `handleGet`; `src/passkey/registration.js:23` native proxy registration behind an optional permission that is never requested. + +## Goals / Non-Goals + +**Goals** +- A submit leads to a visible offer at once, and an update never duplicates. +- A passkey request cannot claim an origin it does not have. + +**Non-Goals** +- Store release and Safari (see the two sibling changes). +- Changing passkey storage. + +## Decisions + +### D1: A shadow root bar, not a popup + +The prompt is injected in a closed shadow root and takes no input from the page, so page script cannot read or click it. The popup prompt stays as a fallback. + +### D2: Match in the service worker + +The service worker already holds the vault cache; it matches by origin and username and returns the id with the capture, so the content script never sees saved passwords. + +### D3: Registrable suffix rule + +The rpId check follows the WebAuthn rule: equal to the host or a parent domain that is not a public suffix. diff --git a/openspec/changes/clients-extension-save-prompt-and-passkey-origin/proposal.md b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/proposal.md new file mode 100644 index 000000000..8cb3c7755 --- /dev/null +++ b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/proposal.md @@ -0,0 +1,59 @@ +--- +kind: code +--- + +# Offer to save or update a login at once, and pin passkey requests to the page origin + +## Why + +After a form submit the extension captures the login (`browser-extension/src/content/content-script.js:134-150`) but holds it in memory until the user happens to open the popup (`service-worker.js:240`), and `doSaveCapture` updates only when `payload.id` is set (`:198`) while the capture never carries an id, so a changed password creates a duplicate. For passkeys, the content script forwards `data.origin` from the page's own message instead of `location.origin` (`content-script.js:212`) and nothing checks the relying party id against the origin, so a hostile page can ask for an assertion for another site. Five and three competitors rate yes. Both rows are the same extension surface, so they are one change. + +The rows share one screen or service, so they are one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `clients-03` | Be offered to save or update a login after submitting a form. | `partial`: `partial`: a submitted login is captured but the offer appears only when the popup is opened, and an existing login is never updated | +| `clients-05` | Use the extension as a passkey provider on websites. | `partial`: `partial`: passkey create and sign work, but the relay trusts an origin the page supplies and the native proxy path never activates | + +### Demand + +- `clients-03`: no demand row. +- `clients-05`: no demand row. + +### Competitors rated yes + +- `clients-03`, bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/autofill/background/notification.background.ts:638 triggerAddLoginNotification, :663 getEnableAddedLoginPrompt, :144 bgSaveCipher Note: Add-login and change-password prompts after f" +- `clients-03`, onepassword: "https://support.1password.com/save-fill-passwords/ : '1Password will automatically offer to save your login' and asks to update an existing item" +- `clients-03`, passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/all/background_page/controller/webIntegration/webIntegrationController.js:34 autosave opens the save-credentials flow (feature autosave-credentials); passbolt/passbolt_styleguide@v5." +- `clients-03`, keeper: "https://docs.keeper.io/user-guides/browser-extensions#save-prompt : 'Keeper will offer to save a password to the vault, if you login manually on a site'; 'Prompt to Change' policy for updates" +- `clients-03`, nextcloud-passwords: "not in the cloned repos, docs rating kept: marius-wieschollek/passwords@2026.9.0 code not public for this (passwords-webextension repo not read); docs rating kept: https://git.mdns.eu/nextcloud/passwords/-/wikis/Administrators/Fea" +- `clients-05`, bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/autofill/fido2/background/fido2.background.ts; libs/common/src/platform/services/fido2/fido2-authenticator.service.ts:188 creates passkey in a login Note: The extension intercepts W" +- `clients-05`, onepassword: "https://support.1password.com/save-use-passkeys/ : save and sign in with passkeys in the browser extension" +- `clients-05`, keeper: "https://docs.keeper.io/user-guides/browser-extensions#passkeys : create a passkey and log in with a passkey through KeeperFill" + +## What Changes + +- Show an in-page prompt (a closed shadow root bar) after a submit: Save, Update or Not now, with Update offered when a saved login matches the site and username. +- Match the captured login against saved logins by origin and username and send the matched id with the capture. +- Take the origin for passkey requests from `location.origin` in the content script and check that the relying party id is a registrable suffix of it before any assertion. +- Request the optional native proxy permission when the user enables the passkey provider, or remove the dead path. + +## Capabilities + +### New Capabilities + +- `clients-save-prompt` +- `clients-passkey-origin` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Extension**: `content-script.js`, `service-worker.js`, `popup.js`, `src/passkey/orchestrator.js`, manifest permissions. +- **Backend**: none. +- **Security**: closes the forged origin defect recorded on `clients-05`. +- **Dependency**: none; store release is `clients-extension-store-release`. diff --git a/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md new file mode 100644 index 000000000..4e2a38541 --- /dev/null +++ b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Passkey origin binding + +The extension MUST derive the origin of a passkey request from the page location in the content script and MUST refuse a request whose relying party id is not equal to, or a registrable suffix of, that origin's host. The `clientDataJSON` origin MUST be that derived origin. + +#### Scenario: A page asks for another site's rpId + +- **GIVEN** a page on evil.example calling navigator.credentials.get with rpId bank.example +- **WHEN** the request reaches the extension +- **THEN** the extension refuses and no assertion is created + +#### Scenario: A page uses its own rpId + +- **GIVEN** a page on login.bank.example with rpId bank.example +- **WHEN** the user consents +- **THEN** the assertion is created and its client data origin is https://login.bank.example diff --git a/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md new file mode 100644 index 000000000..8efc9ede2 --- /dev/null +++ b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Save or update prompt + +After a login form is submitted, the extension MUST show a prompt on the page offering to save the login. When a saved login exists for the same origin and username with a different password, the prompt MUST offer Update and MUST update that secret instead of creating a new one. The prompt MUST NOT appear for a submit that matches an existing saved password, and MUST NOT be readable by page scripts. + +#### Scenario: A new login is offered + +- **GIVEN** a user logged in to the extension who submits a login form on a site with no saved login +- **WHEN** the page reloads after the submit +- **THEN** a prompt offers Save, and Save creates one secret + +#### Scenario: A changed password is offered as an update + +- **GIVEN** a saved login for the same site and username +- **WHEN** the user submits a different password +- **THEN** the prompt offers Update and choosing it changes that secret without creating a duplicate + +#### Scenario: An unchanged login is not offered + +- **GIVEN** a saved login +- **WHEN** the user submits the same password +- **THEN** no prompt appears diff --git a/openspec/changes/clients-extension-save-prompt-and-passkey-origin/tasks.md b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/tasks.md new file mode 100644 index 000000000..e44f0cb52 --- /dev/null +++ b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/tasks.md @@ -0,0 +1,16 @@ +# Tasks: offer to save or update a login at once, and pin passkey requests to the page origin + +## 1. Save prompt + +- [ ] 1.1 Return a matched secret id from the service worker for a capture and pass it to `doSaveCapture`. Verify: node tests on the service worker for match, no match and unchanged password. +- [ ] 1.2 Inject the closed shadow root prompt from the content script with Save, Update and Not now. Verify: extension test in the existing `tests/extension` harness; Playwright flow with the extension loaded. + +## 2. Passkey origin + +- [ ] 2.1 Use `location.origin` in the content script relay and add the rpId suffix check in the orchestrator. Verify: node tests for own rpId, parent domain, foreign rpId and a public suffix. +- [ ] 2.2 Request the optional native proxy permission on enable, or delete the path. Verify: node test on the enable flow. + +## 3. Close out + +- [ ] 3.1 Set rows `clients-03` and `clients-05` to built and clear their defects, archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md new file mode 100644 index 000000000..82edc7828 --- /dev/null +++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md @@ -0,0 +1,35 @@ +# Design: an inactivity lock that follows activity, and a timeout the user keeps + +## Context + +At development `156cd800`: + +- `src/store/modules/session.js:41` initialises `lastActivity`; `:121` and `:159` set it at unlock; `:194` `checkTimeout` compares against it; `:204` `updateActivity()` is never called. +- `src/App.vue:137-143` renders an in-memory timeout select; `:873-874` `saveTimeout` maps `session`, 10 and 30 minutes and falls back with `|| 600000`; `:497` always starts at `session`; `:779` polls `checkTimeout`. +- `src/components/settings/SessionTimeoutSection.vue:52-66` does GET and PUT of `session_timeout` and is mounted nowhere. +- `browser-extension/src/background/service-worker.js:37-38` already has a true idle lock; the extension is not changed. + +## Goals / Non-Goals + +**Goals** +- The lock means inactivity, everywhere in the web app. +- A timeout choice is remembered. + +**Non-Goals** +- An administrator maximum (`admin-24`, decided no). +- Changing the extension idle lock. +- Lock on system sleep. + +## Decisions + +### D1: Throttle activity events + +A single listener set on `document` calls `updateActivity()` at most once every 15 seconds, so typing does not cost a store write per key. + +### D2: Hold the value as milliseconds with an explicit never + +The store keeps `null` for Nextcloud session and a number for 10 or 30 minutes, so the falsy-zero fallback that caused the defect cannot recur. + +### D3: One control + +The select in `App.vue` is removed and the settings section is the only place to change it, so the saved and the applied value cannot diverge. diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/proposal.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/proposal.md new file mode 100644 index 000000000..b32a45401 --- /dev/null +++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/proposal.md @@ -0,0 +1,57 @@ +--- +kind: code +--- + +# An inactivity lock that follows activity, and a timeout the user keeps + +## Why + +The web vault locks itself after a timeout, but `src/store/modules/session.js:194` compares against `lastActivity`, which is set only at unlock (`:121`, `:159`); `updateActivity()` (`:204`) has no caller. An active user is locked out after the timeout however busy they are. The user's timeout choice in `src/App.vue:873-874` lives in memory only, the component that saves it (`src/components/settings/SessionTimeoutSection.vue:52-66`) is mounted nowhere, and choosing Nextcloud session maps to 0 which `|| 600000` turns into 10 minutes. Four competitors rate yes on each row. Both rows are one service (the session store) and one control (the timeout select), so they are one change. + +The rows share one screen or service, so they are one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `crypto-06` | Lock the vault by hand, and have it lock itself after a period of inactivity. | `partial`: `partial`: the web app auto-lock is a timer from unlock, because `updateActivity` has no caller, so an active user is locked out mid-work | +| `crypto-07` | Choose your own session timeout. | `partial`: `partial`: the chosen timeout applies to the page session only, is never saved, and the Nextcloud session choice silently becomes 10 minutes | + +### Demand + +- `crypto-06`: no demand row. +- `crypto-07`: no demand row. + +### Competitors rated yes + +- `crypto-06`, bitwarden: "bitwarden/clients@web-v2026.9.0 libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts:59 checkVaultTimeout (periodic, :36); apps/web/src/locales/en/messages.json:2536 'lockNow'; apps/browser/src/manifest.v" +- `crypto-06`, onepassword: "https://support.1password.com/unlock-auto-lock/ : 'Lock after system is idle for' minutes, also locks on sleep" +- `crypto-06`, keeper: "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#account-settings : Logout Timer 'to automatically log out a user from Keeper when they are inactive' for Web, Mobile and Desktop" +- `crypto-06`, hashicorp-vault: "hashicorp/vault@v2.1.1 ui/lib/core/addon/components/sidebar/user-menu.hbs:63 Log out; ui/app/services/auth.js:32 IDLE_TIMEOUT 3 min, :382 stops token renewal after idle so the session ends at token expiry; ui/app/components/token-" +- `crypto-07`, bitwarden: "bitwarden/clients@web-v2026.9.0 libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts timeout value and action (lock or log out); apps/web/src/locales/en/messages.json:7534 'vaultTimeout'; bitward" +- `crypto-07`, onepassword: "https://support.1password.com/unlock-auto-lock/ : adjust Auto-Lock minutes; Business presets in https://support.1password.com/unlock-auto-lock-policy/" +- `crypto-07`, keeper: "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#account-settings : the admin timer is the maximum; users choose their own timer up to it ('If a Keeper user's current timer is set greater than this value, it will" +- `crypto-07`, nextcloud-passwords: "marius-wieschollek/passwords@2026.9.0 src/vue/Section/Settings.vue:92-106 'End session after' select (1 to 60 minutes) bound to user.session.lifetime; src/lib/Helper/Settings/UserSettingsHelper.php session/lifetime default 600 Not" + +## What Changes + +- Call `updateActivity()` on pointer, key and scroll events, throttled, so the lock counts inactivity. +- Mount `SessionTimeoutSection` in personal settings, remove the in-memory select from `App.vue`, and load the saved value at unlock. +- Fix the Nextcloud session option so it means no idle timer beyond the Nextcloud session, not 10 minutes. + +## Capabilities + +### New Capabilities + +- `vault-session-lock` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Frontend**: `src/store/modules/session.js`, `src/App.vue`, `SessionTimeoutSection.vue`, the settings page that hosts it. +- **Backend**: none; `session_timeout` is already a user preference (`lib/Service/SettingsService.php:93`). +- **Database**: none. +- **Cross-row**: `admin-24` (an administrator cap on the timeout) is decided no on its own; this change leaves the select ready for a maximum but adds none. diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md new file mode 100644 index 000000000..ec972158b --- /dev/null +++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Inactivity lock + +The system MUST lock the web vault after the configured period without user activity. Pointer movement, key presses, scrolling and touch MUST reset the period. Activity in another browser tab MUST NOT keep a locked tab unlocked, and a lock MUST still clear the master key from memory. + +#### Scenario: An active user is not locked out + +- **GIVEN** a user with a 10 minute timeout who has been working for 25 minutes with clicks every minute +- **WHEN** the user keeps working +- **THEN** the vault stays unlocked + +#### Scenario: An idle user is locked + +- **GIVEN** a user with a 10 minute timeout who stops interacting +- **WHEN** ten minutes pass +- **THEN** the lock screen is shown and the master key is cleared + +#### Scenario: Manual lock still works + +- **GIVEN** an unlocked user +- **WHEN** the user chooses Lock vault from the menu +- **THEN** the lock screen is shown at once + +### Requirement: Saved session timeout + +The system MUST let a user choose a timeout in personal settings, MUST persist it through `PUT` on the session timeout preference, and MUST apply the saved value when the vault is unlocked in a new page load. The option Nextcloud session MUST mean no separate idle timer and MUST NOT be converted to another value. + +#### Scenario: The choice survives a reload + +- **GIVEN** a user who picked 30 minutes in personal settings +- **WHEN** the user reloads the page and unlocks +- **THEN** the vault uses a 30 minute timeout and the select shows 30 minutes + +#### Scenario: Nextcloud session means no idle timer + +- **GIVEN** a user who picked Nextcloud session +- **WHEN** the user stays idle for an hour within the Nextcloud session +- **THEN** the vault does not lock on an idle timer diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md new file mode 100644 index 000000000..f93d1f9a7 --- /dev/null +++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md @@ -0,0 +1,15 @@ +# Tasks: an inactivity lock that follows activity, and a timeout the user keeps + +## 1. Inactivity + +- [ ] 1.1 Attach throttled activity listeners in `App.vue` that call `sessionStore.updateActivity()`. Verify: vitest with fake timers for reset, no reset while idle, and throttling. +- [ ] 1.2 Represent Nextcloud session as `null` in the session store and stop the `|| 600000` fallback. Verify: vitest that `null` never locks and a number does. + +## 2. Saved choice + +- [ ] 2.1 Mount `SessionTimeoutSection` in personal settings, delete the in-memory select and `saveTimeout`, load the saved value in the unlock path. Verify: vitest for load at unlock; Playwright flow pick 30 minutes, reload, unlock, read the select. + +## 3. Close out + +- [ ] 3.1 Set rows `crypto-06` and `crypto-07` to built, clear their defects, archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/portability-cxp-cross-provider-transfer/design.md b/openspec/changes/portability-cxp-cross-provider-transfer/design.md new file mode 100644 index 000000000..9b0a9d399 --- /dev/null +++ b/openspec/changes/portability-cxp-cross-provider-transfer/design.md @@ -0,0 +1,29 @@ +# Design: exchange a vault with another provider through Credential Exchange files + +## Context + +At development `156cd800`: + +- `src/views/SecretList.vue:163` opens the transfer dialog; `src/dialogs/CxpTransferDialog.vue:275` `startReceive` and `:360` `doSend`. +- `src/crypto/cxp.js:110` `createImportRequest`, `:136` `sealForRequest`, `:187` `openEnvelope` are already transport free. +- `lib/Controller/CxpRelayController.php:219` and routes `:400-401` are a same-instance mailbox. +- `src/store/modules/export.js:206` `exportCxpSealed` exports the whole vault. + +## Goals / Non-Goals + +**Goals** +- A user can move a vault to or from a provider that speaks the standard, without an intermediary account. + +**Non-Goals** +- Operating system credential exchange APIs, which a web page cannot reach. +- A hosted public relay. + +## Decisions + +### D1: File and QR transport over the existing crypto + +`cxp.js` already separates the envelope from the mailbox, so a file or QR carries the same request and response the relay carries. Nothing new is trusted. + +### D2: Vectors first + +The first task checks the sealing against the published protocol test vectors, so a mismatch is found before any UI is built. diff --git a/openspec/changes/portability-cxp-cross-provider-transfer/proposal.md b/openspec/changes/portability-cxp-cross-provider-transfer/proposal.md new file mode 100644 index 000000000..cc8107b3c --- /dev/null +++ b/openspec/changes/portability-cxp-cross-provider-transfer/proposal.md @@ -0,0 +1,49 @@ +--- +kind: code +--- + +# Exchange a vault with another provider through Credential Exchange files + +## Why + +Keepiq implements the Credential Exchange Protocol handshake between two sessions on one server (`src/dialogs/CxpTransferDialog.vue`, `src/crypto/cxp.js`, `lib/Controller/CxpRelayController.php`), so it cannot receive from or send to another provider, which is the point of the standard. The send side always sends the whole vault (`src/store/modules/export.js:206`). Bitwarden and 1Password document the standard for direct transfer. Two competitors rate yes. + +One row, one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `portability-08` | Move a vault straight to or from another provider with the Credential Exchange Protocol. | `partial`: `partial`: the sealed handshake works through a relay on the same Nextcloud only, so both sides must be Keepiq; there is no way to a different provider, and sending always sends the whole vault | + +### Demand + +- `portability-08`: no demand row. + +### Competitors rated yes + +- `portability-08`, bitwarden: "code not public in the cloned repos (bitwarden/clients@web-v2026.9.0 has no CXP code; the iOS and Android apps are separate repos); docs rating kept: https://bitwarden.com/help/import-data/ Note: CXP direct import and export is a " +- `portability-08`, onepassword: "https://support.1password.com/import/ : Credential Exchange standard on iOS 26+ and Android 14+ for direct transfer" + +## What Changes + +- Add Create import request as a file or QR: the receive side publishes its HPKE public key and request in the CXP request format so another provider can seal to it, and accepts the sealed CXF file it returns. +- Add Send to another provider: the user pastes or loads another provider's request and downloads a sealed CXF file for it. +- Let the send side choose what goes: all items, a folder or a selection. + +## Capabilities + +### New Capabilities + +- `portability-cxp` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Frontend**: `CxpTransferDialog.vue` gains file and QR modes; `src/crypto/cxp.js` exposes request creation and open for file transport; `export.js` takes an item filter. +- **Backend**: none for file transport; the relay stays for same-server transfers. +- **Security**: sealing is HPKE to the requester key as the protocol defines; no new key material is stored. +- **Risk**: interoperability depends on the other provider's current draft of the protocol; the task list starts with a test against the published test vectors. diff --git a/openspec/changes/portability-cxp-cross-provider-transfer/specs/portability-cxp/spec.md b/openspec/changes/portability-cxp-cross-provider-transfer/specs/portability-cxp/spec.md new file mode 100644 index 000000000..f337171db --- /dev/null +++ b/openspec/changes/portability-cxp-cross-provider-transfer/specs/portability-cxp/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Cross provider transfer + +The system MUST let a user create a CXP import request that another provider can answer without a Keepiq relay, and MUST accept the sealed CXF response as a file. The system MUST also let a user seal an export to a request produced by another provider and download it. Only the recipient's public key from the request MUST be used to seal; the sealed file MUST NOT be readable by Keepiq or the relay. + +#### Scenario: A user receives from another provider + +- **GIVEN** a user on the secret list choosing Encrypted transfer and Receive from another provider +- **WHEN** the user downloads the request file, has the other provider seal an export to it and loads the response file +- **THEN** the import wizard opens with the received items + +#### Scenario: A user sends to another provider + +- **GIVEN** a user with a request file from another provider +- **WHEN** the user loads it, chooses one folder and confirms +- **THEN** a sealed file is downloaded that contains only that folder's items + +#### Scenario: A tampered response is refused + +- **GIVEN** a user loading a response file whose envelope was altered +- **WHEN** the file is opened +- **THEN** the dialog says the file could not be verified and imports nothing + +### Requirement: Choose what to send + +The send side MUST let the user pick all items, one folder or a selection, and the sealed file MUST contain only that choice. + +#### Scenario: A selection is sealed + +- **GIVEN** a user with three items selected on the list +- **WHEN** the user starts Send and confirms +- **THEN** the sealed file holds exactly the three items diff --git a/openspec/changes/portability-cxp-cross-provider-transfer/tasks.md b/openspec/changes/portability-cxp-cross-provider-transfer/tasks.md new file mode 100644 index 000000000..8e33733b4 --- /dev/null +++ b/openspec/changes/portability-cxp-cross-provider-transfer/tasks.md @@ -0,0 +1,16 @@ +# Tasks: exchange a vault with another provider through Credential Exchange files + +## 1. Protocol + +- [ ] 1.1 Verify `createImportRequest`, `sealForRequest` and `openEnvelope` against the published CXP test vectors and fix drift. Verify: vitest with the vectors. + +## 2. Receive and send + +- [ ] 2.1 Add request-as-file and response-as-file to the receive path of the dialog. Verify: vitest of a full request, foreign seal and open; Playwright flow with a fixture response file. +- [ ] 2.2 Add Send to another provider with a loaded request file, and the item filter (all, folder, selection) in `export.js`. Verify: vitest that only the chosen items are sealed. + +## 3. Close out + +- [ ] 3.1 Add strings to every shipped locale. Verify: `npm run test:l10n`. +- [ ] 3.2 Set row `portability-08` to built and archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/portability-import-field-mapping/design.md b/openspec/changes/portability-import-field-mapping/design.md new file mode 100644 index 000000000..8f86cab34 --- /dev/null +++ b/openspec/changes/portability-import-field-mapping/design.md @@ -0,0 +1,29 @@ +# Design: adjust the column mapping in the import wizard + +## Context + +At development `156cd800`: + +- `src/dialogs/ImportWizardDialog.vue:66-117` preview table; `:468` masks sensitive cells but nothing sets `revealed[key]`; `:506` `parseFile` call passes only the passphrase. +- `src/import/parsers/csv.js:120` accepts `options.mapping`; `:142` declares `adjustableMapping`. +- `src/store/modules/import.js:57` holds a `mapping` state that nothing writes. +- Vendor formats (Bitwarden, 1Password, and so on) have fixed mappings and keep them. + +## Goals / Non-Goals + +**Goals** +- The user controls the column mapping of a generic CSV before anything is stored. + +**Non-Goals** +- Mapping for vendor formats. +- Saving a mapping as a preset. + +## Decisions + +### D1: Mapping for generic CSV only + +Vendor exports have a known shape; showing selects for them adds a way to break a working import. The step appears only when the parser reports `adjustableMapping`. + +### D2: The store owns the mapping + +The wizard writes the mapping into the import store and both the preview and the import read it from there, so they cannot disagree. diff --git a/openspec/changes/portability-import-field-mapping/proposal.md b/openspec/changes/portability-import-field-mapping/proposal.md new file mode 100644 index 000000000..821e4fc5c --- /dev/null +++ b/openspec/changes/portability-import-field-mapping/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +--- + +# Adjust the column mapping in the import wizard + +## Why + +The import wizard shows a five-row preview (`src/dialogs/ImportWizardDialog.vue:66-117`) but the user cannot change which column feeds which field. The CSV parser already accepts `options.mapping` and declares `adjustableMapping` (`src/import/parsers/csv.js:120,142`), yet the wizard calls `parseFile(text, format, {passphrase})` (`:506`) and the import store's `mapping` state (`src/store/modules/import.js:57`) is never written. A wrongly guessed column silently imports a password as a note. Keeper and Nextcloud Passwords rate yes. + +One row, one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `portability-03` | Preview and adjust the field mapping before importing. | `partial`: `partial`: the wizard previews the first five rows read-only; the CSV parser accepts a mapping but the wizard never passes one | + +### Demand + +- `portability-03`: no demand row. + +### Competitors rated yes + +- `portability-03`, keeper: "https://docs.keeper.io/user-guides/web-vault#field-mapping : field mapping screen ('Click any field to open a dropdown menu'), then 'a summary screen will display a preview of your vault' before import" +- `portability-03`, nextcloud-passwords: "marius-wieschollek/passwords@2026.9.0 src/vue/Components/Import.vue:172-189 'Preview Line' select and csv-mapping field selectors via csvFieldMapping() Note: Custom CSV imports show a preview row and let you map each column; prede" + +## What Changes + +- Add a mapping step for CSV imports: one select per target field (name, url, login, password, notes, folder, type) pre-filled from the detected header. +- Re-run the preview when the mapping changes, and pass the mapping to `parseFile`. +- Make masked preview cells revealable, which the current code cannot do. + +## Capabilities + +### New Capabilities + +- `portability-import-mapping` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Frontend**: `ImportWizardDialog.vue` (split the mapping step into its own dialog file per the modal-isolation gate), `import.js` store, `csv.js` call site. +- **Backend**: none; import is client-side then batch create. +- **Database**: none. diff --git a/openspec/changes/portability-import-field-mapping/specs/portability-import-mapping/spec.md b/openspec/changes/portability-import-field-mapping/specs/portability-import-mapping/spec.md new file mode 100644 index 000000000..b95995f13 --- /dev/null +++ b/openspec/changes/portability-import-field-mapping/specs/portability-import-mapping/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Adjustable CSV mapping + +The import wizard MUST show, for a generic CSV file, the detected mapping from each target field to a source column and MUST let the user change any of them before the import starts. The preview MUST update to the changed mapping, and the import MUST use exactly the mapping shown. + +#### Scenario: A user fixes a wrong guess + +- **GIVEN** a user importing a CSV whose password column is named Secret Key +- **WHEN** the wizard guessed Notes for it and the user picks Password for that column +- **THEN** the preview shows the values under Password and the import stores them as passwords + +#### Scenario: A required field is unmapped + +- **GIVEN** a user in the mapping step +- **WHEN** the user sets Name to no column +- **THEN** the Import button is disabled and the step says a name column is required + +### Requirement: Revealing sensitive preview cells + +The preview MUST mask login and password cells and MUST let the user reveal one cell at a time. + +#### Scenario: A user checks a password cell + +- **GIVEN** the preview of a CSV import +- **WHEN** the user chooses Reveal on one password cell +- **THEN** that cell shows its value and the others stay masked diff --git a/openspec/changes/portability-import-field-mapping/tasks.md b/openspec/changes/portability-import-field-mapping/tasks.md new file mode 100644 index 000000000..c5b7c9a49 --- /dev/null +++ b/openspec/changes/portability-import-field-mapping/tasks.md @@ -0,0 +1,13 @@ +# Tasks: adjust the column mapping in the import wizard + +## 1. Mapping + +- [ ] 1.1 Write and read `mapping` in the import store and pass it to `parseFile`. Verify: vitest that a changed mapping changes the parsed rows. +- [ ] 1.2 Add the mapping step as its own dialog component with the target field selects and the required-name rule. Verify: vitest for the disabled Import button; Playwright flow import a CSV with a renamed column. +- [ ] 1.3 Wire the per-cell reveal in the preview. Verify: vitest that only the chosen cell is revealed. + +## 2. Close out + +- [ ] 2.1 Add strings to every shipped locale. Verify: `npm run test:l10n`. +- [ ] 2.2 Set row `portability-03` to built, clear its defects, archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/sharing-group-share-entry-point/design.md b/openspec/changes/sharing-group-share-entry-point/design.md new file mode 100644 index 000000000..d2386cac3 --- /dev/null +++ b/openspec/changes/sharing-group-share-entry-point/design.md @@ -0,0 +1,30 @@ +# Design: share a secret with a Nextcloud group from the secret sidebar + +## Context + +At development `156cd800`: + +- `lib/Controller/GroupShareController.php` `index`, `create` (`:103`), `destroy` (`:143`), `approveNewMember` (`:179`), `denyNewMember` (`:233`) are routed at `appinfo/routes.php:133-135` and onward. +- `lib/Service/GroupShareService.php:100` `createGroupShare($secretId, $groupId, $userId)` encrypts per member server-side; `:224` `getGroupMembers`; `:250` `handleNewGroupMember`. +- `src/components/share/GroupShareForm.vue` takes a free text group id and a `members` prop no caller supplies, and calls `useShareStore.encryptForRecipient`. +- `src/components/SecretDetailSidebar.vue:680-687` mounts the sharing components for owners and recipients. +- `grep -rn group-shares src browser-extension cli` finds no caller. + +## Goals / Non-Goals + +**Goals** +- An owner reaches the finished group share backend from the UI. + +**Non-Goals** +- Changing the group share crypto. +- Group roles on a share (`sharing-team-folder-manager-role`). + +## Decisions + +### D1: The server encrypts for members + +The backend already builds each member copy, so the form drops the per-member client encryption loop and only sends the group id. This removes the `members` prop nobody supplied. + +### D2: Pick, do not type + +A group search select replaces the free text field so a typo cannot target the wrong group. diff --git a/openspec/changes/sharing-group-share-entry-point/proposal.md b/openspec/changes/sharing-group-share-entry-point/proposal.md new file mode 100644 index 000000000..023dcca2c --- /dev/null +++ b/openspec/changes/sharing-group-share-entry-point/proposal.md @@ -0,0 +1,52 @@ +--- +kind: code +--- + +# Share a secret with a Nextcloud group from the secret sidebar + +## Why + +A user cannot share a secret with a Nextcloud group today. The backend is finished: `POST /api/v1/secrets/{secretId}/group-shares` (`appinfo/routes.php:134`) and `GroupShareService::createGroupShare()` (`lib/Service/GroupShareService.php:100`). The form `src/components/share/GroupShareForm.vue` is registered but has no opener, and its submit path calls the per-user store rather than the group route. Five competitors rate yes. It is a share-path completion, the same class as the shipped user sharing. + +One row, one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `sharing-02` | Share a secret with a Nextcloud group. | `no`: `no`: `GroupShareController` and `GroupShareService` are complete, but nothing in the app opens the group share form or calls the group-share routes | + +### Demand + +- `sharing-02`: no demand row. + +### Competitors rated yes + +- `sharing-02`, bitwarden: "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/GroupsController.cs:23 organizations/{orgId}/groups, :123 POST, :147 PUT with collection access; bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizati" +- `sharing-02`, onepassword: "https://support.1password.com/custom-groups/ : 'give everyone in a group access to specific vaults and assign vault permissions'" +- `sharing-02`, passbolt: "passbolt/passbolt_api@v5.16.0 src/Controller/Share/ShareController.php:101 share (groups are AROs); passbolt/passbolt_styleguide@v5.16.0 src/react-extension/components/Share/GroupPermissionItem.js, ShareDialog.js:515 Note: Groups " +- `sharing-02`, keeper: "https://docs.keeper.io/enterprise-guide/teams : 'Teams can be added to Shared Folders in the vault', teams provisioned from the IdP via SCIM or AD Bridge" +- `sharing-02`, hashicorp-vault: "hashicorp/vault@v2.1.1 ui/app/models/identity/group.js:15 fields name, type, policies, metadata (internal or external group); ui/app/router.js access.identity create/edit routes; vault/identity_store_util.go:3164 refreshExternalGr" + +## What Changes + +- Add a Share with group action to the sharing section of the secret sidebar, next to Share with user. +- Rework `GroupShareForm.vue` to pick a group (search over the caller's Nextcloud groups) and to call a `useGroupShareStore` action that posts to the group-share route. +- List the group shares of a secret with a revoke action, using `GET /api/v1/secrets/{secretId}/group-shares` and `DELETE /api/v1/group-shares/{id}`. + +## Capabilities + +### New Capabilities + +- `sharing-group` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Frontend**: `SecretDetailSidebar.vue`, `GroupShareForm.vue`, a new `src/store/modules/groupShare.js`. +- **Backend**: a group search endpoint limited to the caller's visible groups if none exists; the group-share routes are unchanged. +- **Database**: none. +- **Security**: the group id comes from a picker but the server already validates membership visibility; the change adds a test that a user cannot share into a group they cannot see. diff --git a/openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md b/openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md new file mode 100644 index 000000000..eb2b74038 --- /dev/null +++ b/openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Share with a group + +The system MUST let the owner of a secret share it with a Nextcloud group from the secret detail sidebar. The action MUST call `POST /api/v1/secrets/{secretId}/group-shares` and MUST show how many members received the share and how many were skipped for lack of an active encryption suite. Members who join the group later MUST follow the existing approval path of `GroupShareService::handleNewGroupMember()`. + +#### Scenario: An owner shares with a group + +- **GIVEN** an owner viewing a secret in the sidebar at /secrets and a group Finance with four members of whom three have an encryption suite +- **WHEN** the owner picks Share with group, selects Finance and confirms +- **THEN** the sidebar lists a Finance group share and says three members received it and one was skipped + +#### Scenario: A non-owner cannot share with a group + +- **GIVEN** a recipient who holds a shared copy +- **WHEN** the recipient opens the sidebar +- **THEN** the Share with group action is not offered + +#### Scenario: The owner revokes a group share + +- **GIVEN** a secret shared with Finance +- **WHEN** the owner revokes the Finance share in the sidebar +- **THEN** the group share is gone and members of Finance lose their copies diff --git a/openspec/changes/sharing-group-share-entry-point/tasks.md b/openspec/changes/sharing-group-share-entry-point/tasks.md new file mode 100644 index 000000000..cd5ce1197 --- /dev/null +++ b/openspec/changes/sharing-group-share-entry-point/tasks.md @@ -0,0 +1,16 @@ +# Tasks: share a secret with a Nextcloud group from the secret sidebar + +## 1. Wire the form + +- [ ] 1.1 Create `src/store/modules/groupShare.js` with list, create and revoke actions. Verify: vitest with mocked axios asserting the three routes and payloads. +- [ ] 1.2 Rework `GroupShareForm.vue` to a group picker and the store action, and open it from the sidebar. Verify: vitest on the form; Playwright flow share with a group as owner, see the result counts. +- [ ] 1.3 List and revoke group shares in the sidebar. Verify: Playwright flow revoke, recipient loses access. + +## 2. Backend check + +- [ ] 2.1 Add a PHPUnit test that a user cannot create a group share for a secret they do not own and cannot target a group they cannot see. Verify: PHPUnit; hydra no-admin-idor gate. + +## 3. Close out + +- [ ] 3.1 Set row `sharing-02` to built, clear its defect, archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/vault-custom-field-kinds-and-ssh-key/design.md b/openspec/changes/vault-custom-field-kinds-and-ssh-key/design.md new file mode 100644 index 000000000..1d618a8fe --- /dev/null +++ b/openspec/changes/vault-custom-field-kinds-and-ssh-key/design.md @@ -0,0 +1,36 @@ +# Design: hidden custom fields and a real SSH key item + +## Context + +At development `156cd800`: + +- `src/components/AdditionalFieldsEditor.vue:50` renders each value in a plain `NcTextField`; the field model is `{name, value}`. +- `src/store/modules/secret.js:313-321` decrypts `additionalFields` from JSON and `:377` encrypts it before `POST /api/v1/secrets` (`appinfo/routes.php:89`); the server never sees the shape. +- `src/components/SecretDetailSidebar.vue:570-577` renders each additional field value as plain `
` text. +- `lib/Repair/SeedSecretTypes.php:64` seeds `ssh_key`; `src/dialogs/SecretCreateDialog.vue:95-108` gives it the generic key field. +- `src/cxf/cxf.js:205,355` maps CXF `ssh-key` items and custom fields to and from secrets. + +## Goals / Non-Goals + +**Goals** +- A value that must not be shown by default can be marked hidden and stays hidden until asked for. +- An SSH key pair is one item with its own fields, and a pair can be generated without leaving the browser. + +**Non-Goals** +- Linked or file field kinds. +- Serving the key over an SSH agent; that is `clients-ssh-agent`. +- A server-side key generator. + +## Decisions + +### D1: The kind lives in the encrypted blob + +Adding a `kind` property to each entry of the encrypted additional fields needs no migration and no server change, and an old blob reads as text. A separate column would leak which fields are hidden. + +### D2: Generate in the browser + +The private key is created with WebCrypto in the create dialog and encrypted with the item, so it is never in plain text on the server. Ed25519 is used where `crypto.subtle.generateKey` supports it, otherwise RSA 4096. + +### D3: Fingerprint is derived, not typed + +A SHA256 fingerprint is computed from the public key on save so it can never disagree with the key. diff --git a/openspec/changes/vault-custom-field-kinds-and-ssh-key/proposal.md b/openspec/changes/vault-custom-field-kinds-and-ssh-key/proposal.md new file mode 100644 index 000000000..60609ceef --- /dev/null +++ b/openspec/changes/vault-custom-field-kinds-and-ssh-key/proposal.md @@ -0,0 +1,60 @@ +--- +kind: code +--- + +# Hidden custom fields and a real SSH key item + +## Why + +A person can add named custom fields to an item, but every value is a plain text field and is printed unmasked on the detail sidebar (`src/components/AdditionalFieldsEditor.vue:50`, `src/components/SecretDetailSidebar.vue:570-577`). A recovery code or an API pin typed there is readable by anyone looking over a shoulder. The SSH Key type is seeded (`lib/Repair/SeedSecretTypes.php:64`) but the create dialog offers one value field for it (`src/dialogs/SecretCreateDialog.vue:95-108`), so a key pair cannot be stored as a key pair. Both rows sit in the vault area, the core area, with six and three competitors rating yes. They share one form (the create and edit dialogs) and one blob (the encrypted additional fields), so they are one change. + +The rows share one screen or service, so they are one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `vault-11` | Add your own custom fields to an item, including hidden ones. | `partial`: `partial`: named custom fields exist and are stored encrypted, but there is no hidden kind, so every value is typed and shown as plain text | +| `vault-14` | Store an SSH key pair as its own item type. | `partial`: `partial`: an SSH Key type exists but holds one value field, with no separate private key, public key or fingerprint and no key pair generation | + +### Demand + +- `vault-11`: no demand row. +- `vault-14`: no demand row. + +### Competitors rated yes + +- `vault-11`, bitwarden: "bitwarden/clients@web-v2026.9.0 libs/common/src/vault/enums/field-type.enum.ts:4 Text, :5 Hidden, :6 Boolean, :7 Linked; libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts:244 addField, :200 hidden fiel" +- `vault-11`, onepassword: "https://support.1password.com/custom-fields/ : 11 field types including Password ('copy, reveal, or enlarge')" +- `vault-11`, passbolt: "passbolt/passbolt_api@v5.16.0 config/Migrations/20250704120736_V530AddCustomFieldStandaloneResourceType.php, plugins/PassboltCe/ResourceTypes/src/Model/Entity/ResourceType.php:56 v5-custom-fields; passbolt/passbolt_styleguide@v5.1" +- `vault-11`, keeper: "https://docs.keeper.io/user-guides/web-vault#custom-fields : custom fields incl. 'Hidden Field', 'Security Question & Answer', 'Multi-line Text'" +- `vault-11`, hashicorp-vault: "hashicorp/vault@v2.1.1 ui/lib/core/addon/components/kv-object-editor.hbs:25 free key input per row, :36 MaskedInput for values when @isMasked; ui/lib/kv/addon/components/kv-create-edit-form.hbs:42 KvObjectEditor Note: A KV secret " +- `vault-11`, nextcloud-passwords: "marius-wieschollek/passwords@2026.9.0 src/vue/Dialog/CreatePassword/CustomFields/CustomFieldType.vue:14-19 types text, secret, email, url, file, data; src/vue/Dialog/CreatePassword.vue:132 up to 20 custom fields; src/vue/Dialog/Cr" +- `vault-14`, bitwarden: "bitwarden/server@v2026.9.1 src/Core/Vault/Enums/CipherType.cs:11 SSHKey = 5; bitwarden/clients@web-v2026.9.0 libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts:55 privateKey field (public key and fing" +- `vault-14`, onepassword: "https://support.1password.com/item-categories/ : 'SSH Key: contain an option to generate new SSH keys or import existing'" +- `vault-14`, keeper: "https://docs.keeper.io/user-guides/record-types : 'SSH Key: SSH information, such as public and private key strings'" + +## What Changes + +- Give each custom field a kind: text, hidden or boolean. Hidden values render masked with a reveal and copy control, in the editor and on the detail sidebar. +- Give the SSH Key type its own form: private key, public key and fingerprint, with a Generate key pair action that runs in the browser (WebCrypto Ed25519 where the browser supports it, else RSA 4096) and an Import from file action. +- Keep the encrypted blob shape backward compatible: a field without a `kind` reads as text. +- Map the new fields through the CXF export and import in `src/cxf/cxf.js` so a hidden field and an SSH key round trip. + +## Capabilities + +### New Capabilities + +- `vault-custom-fields` +- `vault-ssh-key-item` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Frontend**: `AdditionalFieldsEditor.vue`, `SecretDetailSidebar.vue`, `SecretCreateDialog.vue`, `SecretEditDialog.vue`, a new SSH key section component, `src/cxf/cxf.js`. +- **Backend**: none. The blob is opaque to the server; no migration, no route. +- **Security**: the private key is generated and encrypted in the browser. A hidden field is a display control only; the value is as encrypted as before. +- **l10n**: new strings in every locale the app ships. diff --git a/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-custom-fields/spec.md b/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-custom-fields/spec.md new file mode 100644 index 000000000..64ed915db --- /dev/null +++ b/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-custom-fields/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Hidden custom fields + +The system MUST let the owner of a secret give each custom field a kind of `text`, `hidden` or `boolean` in the create and edit dialogs. A `hidden` value MUST be rendered masked in the editor and on the detail sidebar until the user chooses Reveal, and MUST have a Copy action that does not reveal it. The kind MUST be stored inside the same encrypted additional-fields blob as the name and value, and a field without a kind MUST be read as `text`. + +#### Scenario: An owner hides a recovery code + +- **GIVEN** an owner editing a login at /secrets with an existing text custom field named Recovery code +- **WHEN** the owner sets the field kind to Hidden and saves +- **THEN** the detail sidebar shows the value as dots and a Reveal button, and Copy places the real value on the clipboard + +#### Scenario: An old secret still opens + +- **GIVEN** a secret saved before this change whose additional fields have no kind +- **WHEN** the owner opens its detail sidebar +- **THEN** every custom field shows as plain text exactly as before + +### Requirement: Hidden values in exports + +Export and import through CXF MUST carry the field kind: a `hidden` field MUST be exported as a concealed string field and imported back as `hidden`. + +#### Scenario: A hidden field survives a CXF round trip + +- **GIVEN** a secret with one hidden and one text custom field +- **WHEN** the owner exports it as CXF and imports the file into an empty vault +- **THEN** the imported secret has both fields and the second one is still hidden diff --git a/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-ssh-key-item/spec.md b/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-ssh-key-item/spec.md new file mode 100644 index 000000000..b200b204c --- /dev/null +++ b/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-ssh-key-item/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: SSH key item form + +The system MUST show private key, public key and fingerprint fields when the item type is SSH Key, and MUST derive the fingerprint from the public key on save. The private key MUST be stored with the secret value and the public key and fingerprint MUST be stored in the same encrypted blob. The form MUST offer Generate key pair, which creates the pair in the browser and never sends the private key anywhere before encryption, and Import from file. + +#### Scenario: A user generates an SSH key pair + +- **GIVEN** a vault user creating a new item of type SSH Key on the secret list +- **WHEN** the user chooses Generate key pair and saves +- **THEN** the item stores the private key encrypted, the public key and a SHA256 fingerprint, and the detail sidebar shows the public key with a Copy action + +#### Scenario: A user imports an existing private key + +- **GIVEN** a vault user with an OpenSSH private key file +- **WHEN** the user chooses Import from file in the SSH Key form +- **THEN** the private key and the derived public key and fingerprint are filled in before saving + +#### Scenario: A malformed key is refused + +- **GIVEN** a vault user in the SSH Key form +- **WHEN** the user pastes text that is not a key into the private key field and saves +- **THEN** the form shows an error on that field and nothing is stored diff --git a/openspec/changes/vault-custom-field-kinds-and-ssh-key/tasks.md b/openspec/changes/vault-custom-field-kinds-and-ssh-key/tasks.md new file mode 100644 index 000000000..2cc63c62d --- /dev/null +++ b/openspec/changes/vault-custom-field-kinds-and-ssh-key/tasks.md @@ -0,0 +1,19 @@ +# Tasks: hidden custom fields and a real SSH key item + +## 1. Custom field kinds + +- [ ] 1.1 Add `kind` to the field model in `AdditionalFieldsEditor.vue` with a kind select, and mask hidden values with reveal and copy controls. Verify: vitest on the editor emitting `{name, value, kind}` and on an old blob reading as text. +- [ ] 1.2 Render hidden fields masked in `SecretDetailSidebar.vue`. Verify: vitest that a hidden value is not in the DOM text until Reveal, and a Playwright flow hide, reveal, copy. +- [ ] 1.3 Carry the kind through `src/cxf/cxf.js` export and import. Verify: vitest round trip with one hidden and one text field. + +## 2. SSH key item + +- [ ] 2.1 Build the SSH key section (private key, public key, fingerprint) and show it for type `ssh_key` in the create and edit dialogs. Verify: vitest that the fingerprint is derived and a malformed key is refused. +- [ ] 2.2 Add Generate key pair and Import from file. Verify: vitest generating a pair and parsing the OpenSSH public key; Playwright flow create, save, open, copy public key. +- [ ] 2.3 Update the CXF mapping for the new SSH fields. Verify: vitest round trip. + +## 3. Close out + +- [ ] 3.1 Add strings to every shipped locale through the writing skill, never `test:l10n:write`. Verify: `npm run test:l10n`. +- [ ] 3.2 Set rows `vault-11` and `vault-14` to built with evidence paths and lines, and archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/vault-defaults-and-recently-used-widget/design.md b/openspec/changes/vault-defaults-and-recently-used-widget/design.md new file mode 100644 index 000000000..4171a644d --- /dev/null +++ b/openspec/changes/vault-defaults-and-recently-used-widget/design.md @@ -0,0 +1,31 @@ +# Design: default item type and view, and a recently used list on the dashboard + +## Context + +At development `156cd800`: + +- `lib/Service/SettingsService.php:98-99` holds `default_secret_type` (default `login`) and `default_view` (default `list`); `src/store/modules/dashboardSettings.js` already allow-lists `default_view`. +- `src/views/DashboardSettingsView.vue:36` binds `form.default_view` and is in no route or registry (matrix defect, issue #208). +- `src/components/settings/SessionTimeoutSection.vue` shows the pattern for a mounted personal settings section. +- `lib/Service/AuditService.php:200` `recentlyAccessed()` calls `findRecentReadsByActor()`; the audit rows carry an object id and a name. +- `src/manifest.json:294` is the `recent-activity-feed` widget on `/api/v1/audit/me`. + +## Goals / Non-Goals + +**Goals** +- Preferences that the server already stores take effect. +- A user sees the secrets they used last, not a log of events. + +**Non-Goals** +- A last-used timestamp on the secret row; that belongs to `vault-favourites-tags-and-last-used`. +- Per-folder defaults. + +## Decisions + +### D1: Mount a section, not the old view + +The old `DashboardSettingsView` mixes unrelated keys. A small `DefaultsSection.vue` next to `SessionTimeoutSection.vue` edits only the two keys and reuses the store; the dead view is deleted. + +### D2: Distinct secrets from the audit query + +The widget endpoint asks `recentlyAccessed()` for more rows than it shows, groups by secret id and drops ids the user no longer holds, so a secret opened twice appears once. diff --git a/openspec/changes/vault-defaults-and-recently-used-widget/proposal.md b/openspec/changes/vault-defaults-and-recently-used-widget/proposal.md new file mode 100644 index 000000000..335440d42 --- /dev/null +++ b/openspec/changes/vault-defaults-and-recently-used-widget/proposal.md @@ -0,0 +1,52 @@ +--- +kind: code +--- + +# Default item type and view, and a recently used list on the dashboard + +## Why + +The backend keeps two per-user preferences, `default_secret_type` and `default_view` (`lib/Service/SettingsService.php:98-99`), but the only form that edits `default_view` is `src/views/DashboardSettingsView.vue`, which no route mounts, and neither the create dialog nor the list reads them (issue #208 is recorded on the matrix row). On the dashboard, `recent-activity-feed` lists the last five audit events of any kind (`src/manifest.json:294`), while `AuditService::recentlyAccessed()` (`lib/Service/AuditService.php:200`) is a finished query with no caller. Both rows are in the vault area, the core area, and both are wiring of finished backend parts, so one change fixes the two. + +The rows share one screen or service, so they are one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `vault-20` | Choose a default item type and default view for new items. | `no`: `no`: the server stores `default_secret_type` and `default_view` but no reached screen edits them and nothing reads them | +| `vault-21` | See the secrets you used most recently on the dashboard. | `partial`: `partial`: the dashboard shows the last five audit events of any kind; the recently-accessed query has no caller | + +### Demand + +- `vault-20`: no demand row. +- `vault-21`: no demand row. + +### Competitors rated yes + +- `vault-20`: no competitor rated yes. +- `vault-21`: no competitor rated yes. + +## What Changes + +- Mount the preferences form in personal settings so a user can pick a default item type and a default list view. +- Preselect the saved default type in the create dialog, and open the secret list in the saved view. +- Add a Recently used widget to the dashboard, fed by `recentlyAccessed()`, one row per secret (not per event), where a row opens the secret. + +## Capabilities + +### New Capabilities + +- `vault-defaults` +- `vault-recently-used` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Frontend**: a preferences section in personal settings, `SecretCreateDialog.vue`, `SecretList.vue`, a new dashboard widget entry in `src/manifest.json`. +- **Backend**: one route that returns recently read secrets joined to the caller's secrets, using `AuditService::recentlyAccessed()`. +- **Database**: none. +- **l10n**: new strings in every shipped locale. diff --git a/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md b/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md new file mode 100644 index 000000000..3a53a93e9 --- /dev/null +++ b/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Default item type and view + +The system MUST let a user choose a default item type and a default list view in personal settings and MUST persist both through the existing preferences endpoint. The create dialog MUST preselect the saved type, and the secret list MUST open in the saved view. A saved type that no longer exists MUST fall back to `login`. + +#### Scenario: A user sets SSH Key as the default type + +- **GIVEN** a signed-in user on personal settings +- **WHEN** the user picks SSH Key as the default item type and saves, then clicks New secret +- **THEN** the create dialog opens with SSH Key selected + +#### Scenario: The list opens in the saved view + +- **GIVEN** a user who saved the grid view +- **WHEN** the user opens /secrets in a new session +- **THEN** the list renders in the grid view + +#### Scenario: A deleted type falls back + +- **GIVEN** a user whose saved default type was deleted by an administrator +- **WHEN** the user clicks New secret +- **THEN** the dialog preselects Login and shows no error diff --git a/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md b/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md new file mode 100644 index 000000000..879d0f564 --- /dev/null +++ b/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Recently used on the dashboard + +The dashboard MUST show a Recently used widget that lists the signed-in user's most recently read secrets, newest first, at most five, one row per secret with its name and a relative time. A row MUST open that secret. The widget MUST list only secrets the user still holds and MUST show an empty state when there are none. + +#### Scenario: A user sees what they opened last + +- **GIVEN** a user who opened three different secrets and one of them twice +- **WHEN** the user opens the dashboard +- **THEN** the widget lists three rows, the twice-opened secret once, newest first + +#### Scenario: A row opens the secret + +- **GIVEN** the Recently used widget with rows +- **WHEN** the user clicks a row +- **THEN** the secret list opens with that secret selected in the sidebar + +#### Scenario: A deleted secret is not listed + +- **GIVEN** a user who read a secret that has since been deleted +- **WHEN** the user opens the dashboard +- **THEN** the widget does not list it diff --git a/openspec/changes/vault-defaults-and-recently-used-widget/tasks.md b/openspec/changes/vault-defaults-and-recently-used-widget/tasks.md new file mode 100644 index 000000000..eabe6a7aa --- /dev/null +++ b/openspec/changes/vault-defaults-and-recently-used-widget/tasks.md @@ -0,0 +1,17 @@ +# Tasks: default item type and view, and a recently used list on the dashboard + +## 1. Defaults + +- [ ] 1.1 Add `DefaultsSection.vue` to personal settings, delete `DashboardSettingsView.vue`, and save through the settings store. Verify: vitest on the section, and a Playwright flow save then reload. +- [ ] 1.2 Read `default_secret_type` in `SecretCreateDialog.vue` and `default_view` in `SecretList.vue`, with the fallback to `login`. Verify: vitest for the saved and the missing type. + +## 2. Recently used + +- [ ] 2.1 Add `GET /api/v1/secrets/recent` returning distinct secrets from `recentlyAccessed()` filtered to the caller's live secrets. Verify: PHPUnit for distinctness, ordering and a deleted secret; hydra route-auth and no-admin-idor gates. +- [ ] 2.2 Add the widget to `src/manifest.json` with a row route to the secret. Verify: Playwright flow open two secrets, open the dashboard, click a row. + +## 3. Close out + +- [ ] 3.1 Add strings to every shipped locale. Verify: `npm run test:l10n`. +- [ ] 3.2 Set rows `vault-20` and `vault-21` to built, close the defect on `vault-20`, archive the change. Verify: parity_verify --strict. + diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index f41d2b2b0..9959d0388 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -539,7 +539,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/vault-trash-and-archive on 2026-09-27. Before: grep -rniF trash|deletedAt lib src: no soft-delete column or restore path. SecretDeleteConfirmDialog.vue:30 and BulkDeleteDialog.vue:35 both say there is no trash and the delete cannot be undone", "owner": "ConductionNL/keepiq", - "note": "Deliberate design choice, stated in the delete-confirmation copy itself, not a gap that was missed. Decision 2026-09-27: specified as vault-trash-and-archive; the bulk-actions spec names a trash a separate change (openspec/specs/bulk-actions/spec.md:94), so it is not a recorded non-goal." + "note": "Deliberate design choice, stated in the delete-confirmation copy itself, not a gap that was missed. Decision 2026-09-27: specified as vault-trash-and-archive; the bulk-actions spec names a trash a separate change (openspec/specs/bulk-actions/spec.md:94), so it is not a recorded non-goal.", + "change": "openspec/changes/vault-trash-and-archive" }, "rowSource": "competitor", "provider": "keepiq", @@ -696,7 +697,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/vault-favourites-tags-and-last-used on 2026-09-27. Before: grep -rniF favourite|favorite lib src: no hits anywhere in the codebase", "owner": "ConductionNL/keepiq", - "note": "No favourites concept exists on the secret entity, store, or UI." + "note": "No favourites concept exists on the secret entity, store, or UI.", + "change": "openspec/changes/vault-favourites-tags-and-last-used" }, "rowSource": "competitor", "provider": "keepiq", @@ -725,7 +727,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/vault-favourites-tags-and-last-used on 2026-09-27. Before: grep -rniF '\"tags\"' lib src: no hits; Secret entity (lib/Db) has no tags column and no additionalFields convention is treated as tags", "owner": "ConductionNL/keepiq", - "note": "No tagging system. A user could ad-hoc name an additional field 'tag' but there is no dedicated tag storage, chip UI, or filter-by-tag." + "note": "No tagging system. A user could ad-hoc name an additional field 'tag' but there is no dedicated tag storage, chip UI, or filter-by-tag.", + "change": "openspec/changes/vault-favourites-tags-and-last-used" }, "rowSource": "competitor", "provider": "keepiq", @@ -755,7 +758,8 @@ "evidence": "src/dialogs/SecretCreateDialog.vue:122 and src/dialogs/SecretEditDialog.vue:125 AdditionalFieldsEditor -> src/store/modules/secret.js:377 encrypts additionalFields blob -> POST /api/v1/secrets (appinfo/routes.php:89); src/components/AdditionalFieldsEditor.vue:50 value is a plain NcTextField; src/components/SecretDetailSidebar.vue:570-577 renders every value as plain
text", "owner": "ConductionNL/keepiq", "reachedOn": "SecretList page (/secrets) -> New secret / Edit secret dialog -> Additional fields section", - "note": "Owners can add, rename and remove named custom fields on create and edit, stored encrypted. There is no hidden or concealed field kind: values are typed in plain text fields and shown unmasked on the detail sidebar." + "note": "Owners can add, rename and remove named custom fields on create and edit, stored encrypted. There is no hidden or concealed field kind: values are typed in plain text fields and shown unmasked on the detail sidebar.", + "change": "openspec/changes/vault-custom-field-kinds-and-ssh-key" }, "rowSource": "own", "provider": "keepiq", @@ -851,7 +855,8 @@ "evidence": "lib/Repair/SeedSecretTypes.php:64 seeds 'ssh_key' => 'SSH Key'; src/dialogs/SecretCreateDialog.vue:95-108 offers only one value field for it; src/cxf/cxf.js:205,355 maps CXF ssh-key items; grep -rni ssh src --include=*.vue: no SSH-specific form, public-key field or key-pair generator", "owner": "ConductionNL/keepiq", "reachedOn": "SecretList page (/secrets) -> New secret dialog -> Type: SSH Key", - "note": "SSH Key exists as its own type, but it stores a single secret value like any other type: there are no separate private key, public key and fingerprint fields and no key-pair generation. A user can put the public key in an additional field by hand." + "note": "SSH Key exists as its own type, but it stores a single secret value like any other type: there are no separate private key, public key and fingerprint fields and no key-pair generation. A user can put the public key in an additional field by hand.", + "change": "openspec/changes/vault-custom-field-kinds-and-ssh-key" }, "rowSource": "competitor", "provider": "keepiq", @@ -921,7 +926,8 @@ "evidence": "Specified in openspec/changes/vault-login-totp-codes on 2026-09-27 for the missing half: a code from a seed kept on a login; the separate authenticator item is built. Before: lib/Repair/SeedSecretTypes.php:67 seeds 'totp' => 'Authenticator (TOTP)'; src/components/SecretDetailSidebar.vue:214-228 renders TotpDisplay only when the type is totp (:1243 isTotp); src/totp/totp.js:7 parses otpauth URI or base32; src/import/parsers/bitwarden.js:70-71 puts a login's seed into additionalFields.totp, which the sidebar shows as plain text, not a code", "owner": "ConductionNL/keepiq", "reachedOn": "SecretList page (/secrets) -> detail sidebar of an Authenticator (TOTP) item -> One-time code row", - "note": "A live one-time code works, but only on a separate Authenticator item whose value is the seed. A seed kept on a login (for example imported from Bitwarden into additionalFields.totp) is shown as raw text and never becomes a code." + "note": "A live one-time code works, but only on a separate Authenticator item whose value is the seed. A seed kept on a login (for example imported from Bitwarden into additionalFields.totp) is shown as raw text and never becomes a code.", + "change": "openspec/changes/vault-login-totp-codes" }, "rowSource": "own", "provider": "keepiq", @@ -1056,7 +1062,8 @@ "issue": "#208", "needsLiveCheck": false } - ] + ], + "change": "openspec/changes/vault-defaults-and-recently-used-widget" }, "rowSource": "own", "provider": "keepiq", @@ -1088,7 +1095,8 @@ "evidence": "src/manifest.json:294 recent-activity-feed widget -> GET /api/v1/audit/me (appinfo/routes.php:378) listing the user's own audit events with item names; lib/Service/AuditService.php:200 recentlyAccessed (secret.read only) has no caller (grep recentlyAccessed lib src: definition only)", "owner": "ConductionNL/keepiq", "reachedOn": "Dashboard page (/) -> Recent activity widget", - "note": "The dashboard shows your last five audit events of any kind, which includes secret reads by name, but it is an activity log rather than a list of recently used secrets and rows do not open the secret. The dedicated recently-accessed query exists but nothing calls it." + "note": "The dashboard shows your last five audit events of any kind, which includes secret reads by name, but it is an activity log rather than a list of recently used secrets and rows do not open the secret. The dedicated recently-accessed query exists but nothing calls it.", + "change": "openspec/changes/vault-defaults-and-recently-used-widget" }, "rowSource": "own", "provider": "keepiq", @@ -1119,7 +1127,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/vault-item-clone-preview-and-print on 2026-09-27. Before: grep -rni 'clone\\|duplicate\\|make a copy' src --include=*.vue: only import-wizard duplicate handling; no clone action in SecretDetailSidebar.vue or SecretList.vue menus", "owner": "ConductionNL/keepiq", - "note": "There is no clone or duplicate action on a secret." + "note": "There is no clone or duplicate action on a secret.", + "change": "openspec/changes/vault-item-clone-preview-and-print" }, "rowSource": "competitor", "provider": "keepiq", @@ -1181,7 +1190,8 @@ "evidence": "Specified in openspec/changes/vault-favourites-tags-and-last-used on 2026-09-27 for the missing half: sorting by date last used; name, date added and date changed are built. Before: src/views/SecretList.vue:787-792 sortOptions name, created_at, updated_at -> src/store/modules/secret.js:140 sort param -> lib/Db/SecretMapper.php:48 SORTABLE_COLUMNS", "owner": "ConductionNL/keepiq", "reachedOn": "SecretList page (/secrets), Filter and sort menu", - "note": "Sort by name, date created and date updated works from the list's filter menu. There is no last-used timestamp on a secret, so sorting by last use is missing." + "note": "Sort by name, date created and date updated works from the list's filter menu. There is no last-used timestamp on a secret, so sorting by last use is missing.", + "change": "openspec/changes/vault-favourites-tags-and-last-used" }, "rowSource": "competitor", "origin": "featureRequest", @@ -1213,7 +1223,8 @@ "evidence": "Specified in openspec/changes/vault-duplicate-finder on 2026-09-27. Before: git grep -i duplicate src lib: only the import wizard dedup (src/dialogs/ImportWizardDialog.vue:7 'duplicates' step); no report or merge over the stored vault", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", - "note": "Duplicates are caught only while importing (portability-05); nothing finds or merges duplicates already stored." + "note": "Duplicates are caught only while importing (portability-05); nothing finds or merges duplicates already stored.", + "change": "openspec/changes/vault-duplicate-finder" }, "rowSource": "competitor", "origin": "featureRequest", @@ -1245,7 +1256,8 @@ "evidence": "Specified in openspec/changes/vault-item-clone-preview-and-print on 2026-09-27. Before: src/components/AttachmentPanel.vue:42-44 offers only 'Download attachment' (store.download); appinfo/routes.php:350 attachment#download returns the ciphertext blob; no preview component", "owner": "ConductionNL/keepiq", "reachedOn": "SecretDetailSidebar attachment panel (download only)", - "note": "Attachments decrypt to a download; there is no in-app preview." + "note": "Attachments decrypt to a download; there is no in-app preview.", + "change": "openspec/changes/vault-item-clone-preview-and-print" }, "rowSource": "competitor", "origin": "featureRequest", @@ -1277,7 +1289,8 @@ "evidence": "Specified in openspec/changes/vault-trash-and-archive on 2026-09-27. Before: git grep -i archiv src lib appinfo: no match", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", - "note": "No archive state; an item is either live or deleted." + "note": "No archive state; an item is either live or deleted.", + "change": "openspec/changes/vault-trash-and-archive" }, "rowSource": "competitor", "origin": "featureRequest", @@ -1309,7 +1322,8 @@ "evidence": "Specified in openspec/changes/vault-website-addresses on 2026-09-27. Before: lib/Db/Secret.php:252 a single 'url' string field; git grep -i 'additionalUrl|urls' src lib: no multi-URL model; browser-extension/src/lib/match.js matches on that one url", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", - "note": "A secret carries one website address." + "note": "A secret carries one website address.", + "change": "openspec/changes/vault-website-addresses" }, "rowSource": "competitor", "origin": "featureRequest", @@ -1371,7 +1385,8 @@ "evidence": "Specified in openspec/changes/vault-item-clone-preview-and-print on 2026-09-27. Before: searched 'qrcode', 'QRCode', 'print(' in src/ lib/: only src/dialogs/ComplianceSnapshotDialog.vue:201 prints the compliance report", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", - "note": "A secret cannot be printed or shown as a QR code." + "note": "A secret cannot be printed or shown as a QR code.", + "change": "openspec/changes/vault-item-clone-preview-and-print" }, "rowSource": "competitor", "origin": "featureRequest", @@ -1401,7 +1416,8 @@ "evidence": "Specified in openspec/changes/vault-website-addresses on 2026-09-27 for the missing half: a preview image of the site; site icons are built. Before: lib/Controller/DashboardController.php:101 favicon_service_url admin setting (off by default) -> src/utils/favicon.js:6 -> src/components/SecretListItem.vue:13 favicon in the list; searched 'screenshot', 'preview' for sites: none", "owner": "ConductionNL/keepiq", "reachedOn": "Secret list, favicon beside each login once an admin sets a favicon service", - "note": "Site icons show when an admin configures a favicon service; there is no screenshot preview." + "note": "Site icons show when an admin configures a favicon service; there is no screenshot preview.", + "change": "openspec/changes/vault-website-addresses" }, "rowSource": "competitor", "origin": "changelog", @@ -1607,7 +1623,8 @@ "issue": null, "needsLiveCheck": true } - ] + ], + "change": "openspec/changes/crypto-session-timeout-and-inactivity-lock" }, "rowSource": "own", "provider": "keepiq", @@ -1653,7 +1670,8 @@ "issue": null, "needsLiveCheck": false } - ] + ], + "change": "openspec/changes/crypto-session-timeout-and-inactivity-lock" }, "rowSource": "own", "provider": "keepiq", @@ -1725,7 +1743,8 @@ "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." + "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.", + "change": "openspec/changes/clients-extension-unlock-lock-and-accounts" }, "rowSource": "competitor", "provider": "keepiq", @@ -1784,7 +1803,8 @@ "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." + "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.", + "change": "openspec/changes/crypto-organisation-account-recovery" }, "rowSource": "competitor", "provider": "keepiq", @@ -1843,7 +1863,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/crypto-vault-encryption-details on 2026-09-27. Before: src/crypto/rsa.js:8 RSA_KEY_BITS = 4096 and src/crypto/aes.js:15 PBKDF2 600000 are code constants only; grep -rni 'algorithm\\|key size\\|RSA-OAEP' src --include=*.vue: only template comments; src/views/CertificateInventoryView.vue:116-137 shows vault certificate owner, subject and expiry, no algorithm or key size", "owner": "ConductionNL/keepiq", - "note": "No screen tells the user which algorithms or key sizes protect the vault. The certificates page shows the vault certificate's subject and expiry, but not RSA-4096, RSA-OAEP, AES-256-GCM or the key derivation settings." + "note": "No screen tells the user which algorithms or key sizes protect the vault. The certificates page shows the vault certificate's subject and expiry, but not RSA-4096, RSA-OAEP, AES-256-GCM or the key derivation settings.", + "change": "openspec/changes/crypto-vault-encryption-details" }, "rowSource": "own", "provider": "keepiq", @@ -2071,7 +2092,8 @@ "evidence": "Specified in openspec/changes/crypto-item-reprompt on 2026-09-27. Before: git grep -i 'reprompt|re-prompt' src browser-extension: no match; master-password re-entry exists only before a plaintext export (src/dialogs/ExportDialog.vue:130 via src/crypto/reauth.js verifyMasterPassword)", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", - "note": "Re-entry of the master password guards export only, not viewing or filling a chosen item." + "note": "Re-entry of the master password guards export only, not viewing or filling a chosen item.", + "change": "openspec/changes/crypto-item-reprompt" }, "rowSource": "competitor", "origin": "featureRequest", @@ -2197,7 +2219,8 @@ "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." + "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.", + "change": "openspec/changes/crypto-new-device-approval" }, "rowSource": "competitor", "origin": "changelog", @@ -2266,7 +2289,8 @@ "issue": null, "needsLiveCheck": false } - ] + ], + "change": "openspec/changes/sharing-group-share-entry-point" }, "rowSource": "own", "provider": "keepiq", @@ -2554,8 +2578,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "partial", "built": { - "state": "building", - "evidence": "src/components/share/DelegationManager.vue (mounted for owners in SecretDetailSidebar.vue:680) wires a reclaim button -> store.reclaimDelegation -> POST /api/v1/secrets/{id}/delegations/reclaim (appinfo/routes.php:155). But the creation half, useDelegationStore.createDelegation (delegation.js:105, POST .../delegations), has no caller anywhere in src/ -- DelegationManager only lists and reclaims, it never offers to create one.", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Reversible: the reclaim half is built and the creation half is a store and dialog gap. Before: src/components/share/DelegationManager.vue (mounted for owners in SecretDetailSidebar.vue:680) wires a reclaim button -> store.reclaimDelegation -> POST /api/v1/secrets/{id}/delegations/reclaim (appinfo/routes.php:155). But the creation half, useDelegationStore.createDelegation (delegation.js:105, POST .../delegations), has no caller anywhere in src/ -- DelegationManager only lists and reclaims, it never offers to create one.", "owner": "ConductionNL/keepiq", "reachedOn": "SecretDetailSidebar -> Delegations section (reclaim only)", "note": "A temporary delegation to a colleague can never be created through the UI, so the reclaim button that IS wired has nothing to act on in normal use: createDelegation (src/store/modules/delegation.js:105) still has no caller in src/. Do not confuse with AdminHandoverPanel.vue (mounted in SecretDetailSidebar.vue:700), a separate vault-admin takeover that has worked end to end since f13ad8e6 (route, controller call and panel, closing #184); it is not an owner-to-colleague temporary handover and does not close this gap.", @@ -2627,8 +2651,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "No feature lets a user who lacks any access to a secret ask its owner for access. The nearest built feature, src/components/share/ShareRequestForm.vue (mounted in SecretDetailSidebar.vue:687 for isRecipient && !isOwner), requires the requester to ALREADY hold a shared copy and asks the owner to share with a DIFFERENT third party (openspec/specs/user-sharing/spec.md#requirement-share-request-recipient-initiated), which is a distinct capability from the one this row describes.", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: No feature lets a user who lacks any access to a secret ask its owner for access. The nearest built feature, src/components/share/ShareRequestForm.vue (mounted in SecretDetailSidebar.vue:687 for isRecipient && !isOwner), requires the requester to ALREADY hold a shared copy and asks the owner to share with a DIFFERENT third party (openspec/specs/user-sharing/spec.md#requirement-share-request-recipient-initiated), which is a distinct capability from the one this row describes.", "owner": "ConductionNL/keepiq", "note": "Rename suggestion: the built and reachable ShareRequestForm feature is 'ask the owner to share this secret with someone else', not 'ask the owner for access to it' for yourself. There is no discovery mechanism for a secret you cannot already see, so a self-access request has nothing to attach to." }, @@ -2764,7 +2788,8 @@ "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." + "note": "Sharing with an account on a different Nextcloud instance is not supported; a password-protected public link (sharing-14) is the closest substitute.", + "change": "openspec/changes/sharing-federated-recipients" }, "rowSource": "competitor", "provider": "keepiq", @@ -2792,7 +2817,8 @@ "built": { "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" + "owner": "ConductionNL/keepiq", + "change": "openspec/changes/sharing-team-folder-manager-role" }, "rowSource": "competitor", "provider": "keepiq", @@ -2942,8 +2968,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "grep -rln 'Comment' lib/Controller/*.php src/store/modules/*.js: no hits. No comment/annotation feature exists anywhere in the app.", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: grep -rln 'Comment' lib/Controller/*.php src/store/modules/*.js: no hits. No comment/annotation feature exists anywhere in the app.", "owner": "ConductionNL/keepiq" }, "rowSource": "competitor", @@ -2973,7 +2999,8 @@ "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." + "note": "Every recipient who can use a secret can also reveal it.", + "change": "openspec/changes/sharing-use-only-and-expiring-shares" }, "rowSource": "competitor", "origin": "tender", @@ -3005,7 +3032,8 @@ "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." + "note": "A share to a colleague lasts until it is revoked by hand.", + "change": "openspec/changes/sharing-use-only-and-expiring-shares" }, "rowSource": "competitor", "origin": "changelog", @@ -3856,8 +3884,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "partial", "built": { - "state": "building", - "evidence": "lib/Controller/ApplicationController.php:338 destroy() -> lib/Service/ApplicationService.php:201-221 delete(): removes the application row and cascades MachineLease + lease-policy rows only. The method's own docblock (line 216-218) reads: 'The full cascade (Secrets + EncryptionSuite + SecretRequests) lands with the dedicated build cycle once those services accept owner_type=application.' SecretService::deleteByApplication() exists but is only called from ApplicationSecretRequestService and SecretPlaceholderCleaner, never from ApplicationService::delete()", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Reversible: ApplicationService::delete cascades leases and lease policies but not secrets. Before: lib/Controller/ApplicationController.php:338 destroy() -> lib/Service/ApplicationService.php:201-221 delete(): removes the application row and cascades MachineLease + lease-policy rows only. The method's own docblock (line 216-218) reads: 'The full cascade (Secrets + EncryptionSuite + SecretRequests) lands with the dedicated build cycle once those services accept owner_type=application.' SecretService::deleteByApplication() exists but is only called from ApplicationSecretRequestService and SecretPlaceholderCleaner, never from ApplicationService::delete()", "owner": "ConductionNL/keepiq", "reachedOn": "Application detail page -> admin delete action -> DELETE /apps/keepiq/api/v1/applications/{id}", "note": "The application-mgmt spec requires that deleting an application permanently deletes the application, its EncryptionSuite, and all its attributed secrets. The shipped delete only removes the application row and its lease rows; secrets and the EncryptionSuite are left behind, contradicting the spec's own scenario.", @@ -3994,7 +4022,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/apps-kubernetes-injection on 2026-09-27. Before: grep -rli 'kubernetes|k8s|helm' lib src cli browser-extension: no hits", "note": "No Kubernetes secret injection (operator, CSI driver, sidecar) exists; keepiq's machine surface is a plain HTTP+JWT API a cluster could call itself, but nothing ships to do that integration.", - "owner": "ConductionNL/keepiq" + "owner": "ConductionNL/keepiq", + "change": "openspec/changes/apps-kubernetes-injection" }, "rowSource": "competitor", "provider": "keepiq", @@ -4023,7 +4052,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/apps-client-libraries-and-ci on 2026-09-27. Before: grep -rli 'github.action|gitlab.ci|.gitlab-ci' lib src cli browser-extension: no hits (only this repo's own CI workflows use GitHub Actions, which is unrelated to a keepiq integration product)", "note": "No ready-made GitHub Actions or GitLab CI step exists; a pipeline would have to install and script the keepiq CLI itself.", - "owner": "ConductionNL/keepiq" + "owner": "ConductionNL/keepiq", + "change": "openspec/changes/apps-client-libraries-and-ci" }, "rowSource": "competitor", "provider": "keepiq", @@ -4049,8 +4079,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "grep -rli 'dynamic.*database|database.*credential|db.*secret.*engine' lib src: no hits; keepiq only stores and serves secrets a human or app already supplied, it does not generate short-lived database credentials itself", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: grep -rli 'dynamic.*database|database.*credential|db.*secret.*engine' lib src: no hits; keepiq only stores and serves secrets a human or app already supplied, it does not generate short-lived database credentials itself", "note": "No dynamic secrets engine (database, cloud IAM, etc.) exists, unlike Vault/OpenBao.", "owner": "ConductionNL/keepiq" }, @@ -4081,7 +4111,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/apps-secret-sync-and-rotation-runner on 2026-09-27. Before: Matrix corrected 2026-09-27 in the openspec pass: the rating is no and nothing rotates a password at the destination service; the evidence below is the reminder and manual mark-rotated flow only. Before: openspec/specs/rotation-expiry-policies/spec.md:9 describes 'expiry, an admin-default and user-override max-age policy, approaching/overdue reminders ... a proven mark-rotated flow'; this is a reminder + manual mark-rotated flow (lib/Controller/RotationController.php, src/store/modules/rotation.js), not automatic rotation of the underlying credential at the target service", "owner": "ConductionNL/keepiq", - "note": "keepiq flags stale/expiring secrets and lets a user mark one rotated, but it never rotates a password at the destination service itself the way Vault/1Password-style rotation connectors do." + "note": "keepiq flags stale/expiring secrets and lets a user mark one rotated, but it never rotates a password at the destination service itself the way Vault/1Password-style rotation connectors do.", + "change": "openspec/changes/apps-secret-sync-and-rotation-runner" }, "rowSource": "competitor", "provider": "keepiq", @@ -4111,7 +4142,8 @@ "evidence": "Specified in openspec/changes/apps-client-libraries-and-ci on 2026-09-27 for the missing half: client libraries for common languages; the Go command-line client is built. Before: cli/ is a single Go CLI (stdlib only) covering human read-only + CI fetch; no client SDKs for other languages exist (no python/node/java package in the repo)", "reachedOn": "keepiq CLI (Go binary, cross-compiled)", "note": "There is one cross-compiled CLI binary, not per-language client libraries; a Python or Node consumer would call the documented HTTP+JWT API directly with no official SDK.", - "owner": "ConductionNL/keepiq" + "owner": "ConductionNL/keepiq", + "change": "openspec/changes/apps-client-libraries-and-ci" }, "rowSource": "competitor", "provider": "keepiq", @@ -4140,7 +4172,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/apps-terraform-provider on 2026-09-27. Before: grep -rli 'terraform' . --include=*.md --include=*.php --include=*.go: no hits", "note": "No Terraform provider exists for managing keepiq secrets/applications as code.", - "owner": "ConductionNL/keepiq" + "owner": "ConductionNL/keepiq", + "change": "openspec/changes/apps-terraform-provider" }, "rowSource": "competitor", "provider": "keepiq", @@ -4198,8 +4231,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "grep -n 'class.*Controller' lib/Controller | grep -i 'encrypt|transit|crypto': only EncryptionSuiteController (manages the user's own suite/keypair), no generic encrypt/decrypt-as-a-service endpoint that takes arbitrary application data", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: grep -n 'class.*Controller' lib/Controller | grep -i 'encrypt|transit|crypto': only EncryptionSuiteController (manages the user's own suite/keypair), no generic encrypt/decrypt-as-a-service endpoint that takes arbitrary application data", "note": "keepiq encrypts secret VALUES it stores; it has no Vault-Transit-style API where an application sends it arbitrary data to encrypt/decrypt without storing it.", "owner": "ConductionNL/keepiq" }, @@ -4230,7 +4263,8 @@ "evidence": "Specified in openspec/changes/apps-secret-sync-and-rotation-runner on 2026-09-27. Before: git grep -i 'aws|azure|key vault' lib src: no match; no outbound secret sync", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", - "note": "No push of secrets to cloud secret stores." + "note": "No push of secrets to cloud secret stores.", + "change": "openspec/changes/apps-secret-sync-and-rotation-runner" }, "rowSource": "competitor", "provider": "keepiq", @@ -4288,8 +4322,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "no", "built": { - "state": "building", - "evidence": "Intermediate: lib/BackgroundJob/RenewIntermediateCertificate.php:68 (daily, appinfo/info.xml:111) -> CertificateAuthorityService.php:242 renewIntermediate -> resignAllActiveSuites. Root: CertificateAuthorityService.php:288 renewRoot only reachable through POST /api/v1/ca/renew-root (lib/Controller/CACertificateController.php:140), which no UI calls (routes-unmatched NO-REF); lib/BackgroundJob/CheckRootCertificateExpiry.php:68 only logs", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Reversible: the intermediate renews on a daily job and the root renews only by hand. Before: Intermediate: lib/BackgroundJob/RenewIntermediateCertificate.php:68 (daily, appinfo/info.xml:111) -> CertificateAuthorityService.php:242 renewIntermediate -> resignAllActiveSuites. Root: CertificateAuthorityService.php:288 renewRoot only reachable through POST /api/v1/ca/renew-root (lib/Controller/CACertificateController.php:140), which no UI calls (routes-unmatched NO-REF); lib/BackgroundJob/CheckRootCertificateExpiry.php:68 only logs", "owner": "ConductionNL/keepiq", "reachedOn": "machine: daily background job (intermediate only)", "note": "The intermediate is renewed automatically, but because of an inverted date diff it is renewed every day rather than 30 days before expiry. The root is never renewed automatically and its expiry warning never fires.", @@ -4443,9 +4477,9 @@ "nextcloud-passwords": "no", "built": { "state": "built", - "evidence": "src/views/CertificateInventoryView.vue:292 'Renew…' -> src/store/modules/certificate.js:86 POST /api/v1/certificates/{id}/renewal-checklist -> lib/Service/CertificateLifecycleService.php:228 (externally issued checklist); suite rows: CertificateInventoryView.vue:308 -> certificate.js:108 POST /api/v1/certificates/suites/{id}/reissue -> CertificateLifecycleService.php:282", + "evidence": "src/views/CertificateInventoryView.vue:292 'Renew\u2026' -> src/store/modules/certificate.js:86 POST /api/v1/certificates/{id}/renewal-checklist -> lib/Service/CertificateLifecycleService.php:228 (externally issued checklist); suite rows: CertificateInventoryView.vue:308 -> certificate.js:108 POST /api/v1/certificates/suites/{id}/reissue -> CertificateLifecycleService.php:282", "owner": "ConductionNL/keepiq", - "reachedOn": "Certificates page -> Renew… on a stored certificate, Re-issue on a suite certificate", + "reachedOn": "Certificates page -> Renew\u2026 on a stored certificate, Re-issue on a suite certificate", "note": "Stored certificates get a fixed checklist for externally issued certificates; certificates Keepiq issued itself (suite certificates) can be re-issued with one click. There is no branch for a stored certificate that Keepiq's CA issued, because Keepiq does not issue those." }, "rowSource": "own", @@ -4473,8 +4507,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "building", - "evidence": "lib/Service/CertificateAuthorityService.php:223 signCsr and lib/Service/CertificateIssuanceService.php:211 exist but no controller calls signCsr (grep '->signCsr(' lib: only the service wrapper); the only CSR the CA signs is an application's registration CSR (lib/Service/ApplicationSuiteProvisioner.php:67), downloadable on src/views/ApplicationDetail.vue:324 -> GET /api/v1/applications/{id}/certificate", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: lib/Service/CertificateAuthorityService.php:223 signCsr and lib/Service/CertificateIssuanceService.php:211 exist but no controller calls signCsr (grep '->signCsr(' lib: only the service wrapper); the only CSR the CA signs is an application's registration CSR (lib/Service/ApplicationSuiteProvisioner.php:67), downloadable on src/views/ApplicationDetail.vue:324 -> GET /api/v1/applications/{id}/certificate", "owner": "ConductionNL/keepiq", "note": "There is no way to issue a certificate for an arbitrary service. The CA only signs keepiq's own suite certificates and the CSR an application submits when it registers with keepiq." }, @@ -4533,8 +4567,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "no", "built": { - "state": "building", - "evidence": "src/views/ApplicationRegisterView.vue:57 mounts src/dialogs/PrivateKeyDownloadDialog.vue when src/store/modules/application.js:136 finds data.private_key, but no PHP code returns a private_key (grep \"'private_key'\" lib: no hits); lib/Service/ApplicationLifecycleService.php:334 only provisions a suite from a supplied CSR", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Note: PrivateKeyDownloadDialog is mounted but no PHP code returns a private_key, so the dialog is dead UI. Before: src/views/ApplicationRegisterView.vue:57 mounts src/dialogs/PrivateKeyDownloadDialog.vue when src/store/modules/application.js:136 finds data.private_key, but no PHP code returns a private_key (grep \"'private_key'\" lib: no hits); lib/Service/ApplicationLifecycleService.php:334 only provisions a suite from a supplied CSR", "owner": "ConductionNL/keepiq", "note": "The one-time private key download is wired in the frontend but the backend never generates a key pair or returns a private key, so the dialog can never open. Applications must bring their own key via a CSR; one registered without a CSR gets no suite.", "defects": [ @@ -4849,7 +4883,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/health-passphrase-generator on 2026-09-27. Before: grep -rli 'passphrase|diceware|wordlist' src lib browser-extension/src cli: hits are only export-backup passphrases and import parsers; KeyGeneratorService has no word mode", "owner": "ConductionNL/keepiq", - "note": "The generator only produces character strings (or regex-shaped ones); there is no word-based passphrase mode." + "note": "The generator only produces character strings (or regex-shaped ones); there is no word-based passphrase mode.", + "change": "openspec/changes/health-passphrase-generator" }, "rowSource": "competitor", "provider": "keepiq", @@ -4875,8 +4910,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "grep -rni 'username generat|email alias|simplelogin|anonaddy|forwarder' src lib browser-extension/src cli: no hits", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: grep -rni 'username generat|email alias|simplelogin|anonaddy|forwarder' src lib browser-extension/src cli: no hits", "owner": "ConductionNL/keepiq", "note": "No username or email-alias generator anywhere in the app, extension or CLI." }, @@ -4903,8 +4938,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "grep -rni 'breachedaccount|email.*breach|breach.*email' src lib: no hits; the only HIBP use is the Pwned Passwords range proxy (lib/Controller/BreachProxyController.php:57)", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: grep -rni 'breachedaccount|email.*breach|breach.*email' src lib: no hits; the only HIBP use is the Pwned Passwords range proxy (lib/Controller/BreachProxyController.php:57)", "owner": "ConductionNL/keepiq", "note": "Breach checking covers password values only, on demand; there is no email or account breach monitoring and no alerting on new breaches." }, @@ -4935,7 +4970,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/health-site-security-checks on 2026-09-27. Before: grep -rni '2fa.directory|twofactorauth|inactive.*2fa' src lib browser-extension/src: no hits", "owner": "ConductionNL/keepiq", - "note": "No inactive two-factor report; the health engine has no such category (src/health/engine.js:114)." + "note": "No inactive two-factor report; the health engine has no such category (src/health/engine.js:114).", + "change": "openspec/changes/health-site-security-checks" }, "rowSource": "competitor", "provider": "keepiq", @@ -4963,7 +4999,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/health-site-security-checks on 2026-09-27. Before: src/health/engine.js flags only weak, reused, stale, compromised, breached; grep for http protocol checks in src and browser-extension/src: no hits", "owner": "ConductionNL/keepiq", - "note": "No insecure-URL (http) finding in the health report or the extension." + "note": "No insecure-URL (http) finding in the health report or the extension.", + "change": "openspec/changes/health-site-security-checks" }, "rowSource": "competitor", "provider": "keepiq", @@ -5054,7 +5091,8 @@ "evidence": "Specified in openspec/changes/health-site-security-checks on 2026-09-27. Before: git grep -i 'passkey' src/health src/store/modules/health.js: no passkey-availability check", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", - "note": "Health report has no passkey-available check." + "note": "Health report has no passkey-available check.", + "change": "openspec/changes/health-site-security-checks" }, "rowSource": "competitor", "origin": "changelog", @@ -5332,8 +5370,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "no", "built": { - "state": "building", - "evidence": "routes GET/POST/DELETE /api/v1/expiry-policies -> lib/Controller/RotationController.php:161,190,224 and store actions src/store/modules/rotation.js:81,108,124 exist, but grep for fetchPolicies|upsertPolicy|deletePolicy in src components: no callers", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Reversible: the expiry policy routes and store actions exist and have no page. Before: routes GET/POST/DELETE /api/v1/expiry-policies -> lib/Controller/RotationController.php:161,190,224 and store actions src/store/modules/rotation.js:81,108,124 exist, but grep for fetchPolicies|upsertPolicy|deletePolicy in src components: no callers", "owner": "ConductionNL/keepiq", "note": "Backend and store are done, but no screen calls them; the admin section says type and folder policies are managed from the vault UI, which has no such editor. Open issue #77.", "defects": [ @@ -5374,7 +5412,8 @@ "evidence": "Specified in openspec/changes/vault-website-addresses on 2026-09-27. Before: searched 'change-password', 'well-known' in src/ lib/ browser-extension/src: no match", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", - "note": "Keepiq stores the website address but never looks up or opens the site's change-password page." + "note": "Keepiq stores the website address but never looks up or opens the site's change-password page.", + "change": "openspec/changes/vault-website-addresses" }, "rowSource": "competitor", "origin": "featureRequest", @@ -5523,7 +5562,8 @@ "issue": null, "needsLiveCheck": true } - ] + ], + "change": "openspec/changes/admin-member-overview-and-offboarding" }, "rowSource": "own", "provider": "keepiq", @@ -5717,7 +5757,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/admin-vault-policies on 2026-09-27. Before: grep -rni 'export_disabled|allow_export|require_2fa|twofactor' lib/Service/AdminSettingsService.php lib/Controller/ExportController.php src/components/settings: no hits", "owner": "ConductionNL/keepiq", - "note": "Keepiq has no policy to require two-factor login or to block personal vault export. Nextcloud can enforce two-factor for the whole login, which also guards keepiq, but that is a server setting, not a vault rule." + "note": "Keepiq has no policy to require two-factor login or to block personal vault export. Nextcloud can enforce two-factor for the whole login, which also guards keepiq, but that is a server setting, not a vault rule.", + "change": "openspec/changes/admin-vault-policies" }, "rowSource": "competitor", "provider": "keepiq", @@ -5748,7 +5789,8 @@ "owner": "ConductionNL/keepiq", "reachedOn": "Nextcloud admin delegation for the Keepiq settings section; vault_admin group for offboarding and for the admin handover panel in the secret sidebar", "note": "There are two coarse levers: Nextcloud's delegation of the whole Keepiq admin section, and a hard-coded vault_admin group that unlocks offboarding and admin handover. The handover now has a route, a controller call and a UI panel (f13ad8e6, closing #184), so both levers work end to end. There is still no role editor and no per-permission role, so a person cannot be given only the permissions they need: partial.", - "defects": [] + "defects": [], + "change": "openspec/changes/admin-scoped-roles" }, "rowSource": "competitor", "provider": "keepiq", @@ -5786,7 +5828,8 @@ "issue": "#37", "needsLiveCheck": false } - ] + ], + "change": "openspec/changes/admin-member-overview-and-offboarding" }, "rowSource": "competitor", "provider": "keepiq", @@ -5815,7 +5858,8 @@ "state": "specified", "evidence": "Specified in openspec/changes/admin-public-api on 2026-09-27. Before: no OpenAPI or admin API docs in docs/; machine routes /api/v1/app/* (appinfo/routes.php:295-321) cover application secrets only; admin endpoints like PUT /api/settings/admin are internal session routes", "owner": "ConductionNL/keepiq", - "note": "The admin screens call internal REST routes that a script could reach with a Nextcloud app password, but there is no documented, versioned admin API or scoped admin token." + "note": "The admin screens call internal REST routes that a script could reach with a Nextcloud app password, but there is no documented, versioned admin API or scoped admin token.", + "change": "openspec/changes/admin-public-api" }, "rowSource": "competitor", "provider": "keepiq", @@ -5935,8 +5979,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "no", "built": { - "state": "building", - "evidence": "src/manifest.json pending-apps-queue widget (visibleWhen pending_apps_count > 0) -> GET /api/v1/applications/pending; lib/Service/DashboardSummaryService.php:155 computes ca_health for admins but no widget renders it and src/components/dashboard/CaHealthCard.vue has no importer", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Reversible: the CA health summary is computed and no widget renders it. Before: src/manifest.json pending-apps-queue widget (visibleWhen pending_apps_count > 0) -> GET /api/v1/applications/pending; lib/Service/DashboardSummaryService.php:155 computes ca_health for admins but no widget renders it and src/components/dashboard/CaHealthCard.vue has no importer", "owner": "ConductionNL/keepiq", "reachedOn": "Dashboard (/) Applications awaiting approval table, admins with pending work only", "note": "Admins see which applications await approval on the dashboard, but cannot approve from there, and the CA status is only in admin settings (CaHealthSection). The dashboard CA card is built but orphaned. The queue's View-all footer needs a live check: the _note at src/manifest.json:208 that ties it to the limit forwarding predates the installed @conduction/nextcloud-vue 2.55.1, which keeps limit, so it is not recorded as a defect.", @@ -5978,7 +6022,8 @@ "evidence": "appinfo/routes.php:75-77 secretType#create/update/destroy -> lib/Controller/SecretTypeController.php; src/store/modules/secretType.js:73 createType has no caller in src (git grep createType src)", "owner": "ConductionNL/keepiq", "reachedOn": "nothing, API only", - "note": "Custom secret types exist in the API and store, but no page lets anyone create one." + "note": "Custom secret types exist in the API and store, but no page lets anyone create one.", + "change": "openspec/changes/admin-secret-type-editor" }, "rowSource": "competitor", "origin": "featureRequest", @@ -6106,7 +6151,8 @@ "evidence": "Specified in openspec/changes/admin-vault-policies on 2026-09-27. Before: lib/Service/AdminSettingsService.php: no ownership or personal-vault policy; git grep -i 'personal vault' lib: descriptive text only", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", - "note": "Every secret starts in the creator's personal vault; nothing forces work logins into a team folder." + "note": "Every secret starts in the creator's personal vault; nothing forces work logins into a team folder.", + "change": "openspec/changes/admin-vault-policies" }, "rowSource": "competitor", "origin": "tender", @@ -6164,8 +6210,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "lib/Service/AdminSettingsService.php:55 VALID_SESSION_TIMEOUTS and :308 default_session_timeout set a DEFAULT only; git grep default_session_timeout src: no caller, so no admin page sets it; src/App.vue:137 user timeout select has no cap", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: Single competitor, changelog signal only, no demand row. Before: lib/Service/AdminSettingsService.php:55 VALID_SESSION_TIMEOUTS and :308 default_session_timeout set a DEFAULT only; git grep default_session_timeout src: no caller, so no admin page sets it; src/App.vue:137 user timeout select has no cap", "owner": "ConductionNL/keepiq", "reachedOn": "nothing, API only", "note": "An admin API stores a default session timeout, but it is not a maximum and no admin page reaches it." @@ -6198,7 +6244,8 @@ "evidence": "Specified in openspec/changes/admin-auto-confirm-members on 2026-09-27. Before: lib/Service/TeamFolderService.php:496 approveJoin returns a fan-out payload the owner's browser must encrypt; src/modals/TeamFolderDialog.vue:469 shows pendingCount and src/store/modules/teamFolder.js:278 runFanOut shares keys when the owner runs it; approveJoin (teamFolder.js:202) has no caller in src", "owner": "ConductionNL/keepiq", "reachedOn": "Team folder dialog, pending members count and fan-out run by the owner", - "note": "A new team folder member gets access only when the owner's browser runs the key fan-out; there is no automatic confirmation." + "note": "A new team folder member gets access only when the owner's browser runs the key fan-out; there is no automatic confirmation.", + "change": "openspec/changes/admin-auto-confirm-members" }, "rowSource": "competitor", "origin": "changelog", @@ -6258,7 +6305,8 @@ "evidence": "Specified in openspec/changes/admin-scheduled-vault-backups on 2026-09-27. Before: searched 'backup' in lib/Command lib/BackgroundJob: no match; the encrypted-backup export (portability-09) is per user and started by hand", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", - "note": "There is no scheduled server-side backup of all vaults and no restore command; an instance relies on the Nextcloud database backup." + "note": "There is no scheduled server-side backup of all vaults and no restore command; an instance relies on the Nextcloud database backup.", + "change": "openspec/changes/admin-scheduled-vault-backups" }, "rowSource": "competitor", "origin": "featureRequest", @@ -6707,7 +6755,8 @@ "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." + "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.", + "change": "openspec/changes/audit-siem-vendor-connectors" }, "rowSource": "competitor", "provider": "keepiq", @@ -6763,8 +6812,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "lib/Service/ComplianceReportService.php:56-70 SECTION_ALLOWLIST holds aggregates only (adoption, secretsPerUser, shareHygiene counts); no per-member access listing in lib/ src/", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: Single competitor, changelog signal only, no demand row. Before: lib/Service/ComplianceReportService.php:56-70 SECTION_ALLOWLIST holds aggregates only (adoption, secretsPerUser, shareHygiene counts); no per-member access listing in lib/ src/", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", "note": "The compliance report counts shares but never lists which member can reach which secret or folder." @@ -6875,7 +6924,8 @@ "issue": null, "needsLiveCheck": false } - ] + ], + "change": "openspec/changes/portability-import-field-mapping" }, "rowSource": "own", "provider": "keepiq", @@ -7063,7 +7113,8 @@ "evidence": "src/views/SecretList.vue:163 'Encrypted transfer (CXP)' -> src/dialogs/CxpTransferDialog.vue:275 startReceive (createImportRequest src/crypto/cxp.js:110) -> POST /api/v1/cxp/relay (appinfo/routes.php:400 -> lib/Controller/CxpRelayController.php:219) -> poll GET relay/{id}/response (routes.php:401) -> cxp.js:187 openEnvelope -> import wizard; send: CxpTransferDialog.vue:360 doSend -> src/store/modules/export.js:206 exportCxpSealed (sealForRequest cxp.js:136)", "owner": "ConductionNL/keepiq", "reachedOn": "SecretList page (/secrets) -> actions menu 'Encrypted transfer (CXP)' -> CXP transfer dialog", - "note": "The HPKE-sealed handshake works through a relay on this Nextcloud instance only, so both sides must be Keepiq sessions on the same server. There is no way to transfer to or from a different provider, and the send side always sends the whole vault." + "note": "The HPKE-sealed handshake works through a relay on this Nextcloud instance only, so both sides must be Keepiq sessions on the same server. There is no way to transfer to or from a different provider, and the send side always sends the whole vault.", + "change": "openspec/changes/portability-cxp-cross-provider-transfer" }, "rowSource": "own", "provider": "keepiq", @@ -7108,7 +7159,8 @@ "issue": null, "needsLiveCheck": true } - ] + ], + "change": "openspec/changes/portability-export-choice-and-restore-fidelity" }, "rowSource": "own", "provider": "keepiq", @@ -7172,7 +7224,8 @@ "evidence": "Specified in openspec/changes/portability-export-choice-and-restore-fidelity on 2026-09-27 for the missing half: choosing by type, by selected items and by fields; whole vault or one folder is built. Before: src/dialogs/ExportDialog.vue:250 scopeOptions (Entire vault or one folder) -> :322 buildScope -> src/export/serializer.js:101 serializeVault collectSubtree (folder plus descendants)", "owner": "ConductionNL/keepiq", "reachedOn": "SecretList page (/secrets) -> actions menu 'Export data' -> Export dialog -> Scope select", - "note": "Scope is either the whole vault or one folder subtree. There is no choice by type, by selected items or of which fields to include, and CXP send is always the whole vault." + "note": "Scope is either the whole vault or one folder subtree. There is no choice by type, by selected items or of which fields to include, and CXP send is always the whole vault.", + "change": "openspec/changes/portability-export-choice-and-restore-fidelity" }, "rowSource": "own", "provider": "keepiq", @@ -7257,7 +7310,8 @@ "issue": null, "needsLiveCheck": false } - ] + ], + "change": "openspec/changes/clients-extension-store-release" }, "rowSource": "own", "provider": "keepiq", @@ -7354,7 +7408,8 @@ "issue": null, "needsLiveCheck": true } - ] + ], + "change": "openspec/changes/clients-extension-save-prompt-and-passkey-origin" }, "rowSource": "own", "provider": "keepiq", @@ -7394,7 +7449,8 @@ "issue": null, "needsLiveCheck": false } - ] + ], + "change": "openspec/changes/clients-extension-store-release" }, "rowSource": "own", "provider": "keepiq", @@ -7446,7 +7502,8 @@ "issue": null, "needsLiveCheck": false } - ] + ], + "change": "openspec/changes/clients-extension-save-prompt-and-passkey-origin" }, "rowSource": "own", "provider": "keepiq", @@ -7486,7 +7543,8 @@ "issue": null, "needsLiveCheck": false } - ] + ], + "change": "openspec/changes/clients-extension-unlock-lock-and-accounts" }, "rowSource": "own", "provider": "keepiq", @@ -7703,7 +7761,8 @@ "issue": null, "needsLiveCheck": true } - ] + ], + "change": "openspec/changes/clients-extension-firefox-and-safari-builds" }, "rowSource": "competitor", "provider": "keepiq", @@ -7733,7 +7792,8 @@ "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." + "note": "SSH keys can be stored as secrets, but nothing exposes them to an SSH agent.", + "change": "openspec/changes/clients-ssh-agent" }, "rowSource": "competitor", "provider": "keepiq", @@ -7953,7 +8013,8 @@ "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." + "note": "Offline mode reads only; edits need the server.", + "change": "openspec/changes/clients-offline-edits" }, "rowSource": "competitor", "origin": "featureRequest", @@ -8017,7 +8078,8 @@ "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." + "note": "The extension pairs with one Nextcloud account at a time; switching means unpairing.", + "change": "openspec/changes/clients-extension-unlock-lock-and-accounts" }, "rowSource": "competitor", "origin": "featureRequest", @@ -8045,8 +8107,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "unknown", "built": { - "state": "none", - "evidence": "searched 'phish', 'blocklist' in browser-extension/ src/ lib/: only browser-extension/src/lib/match.js:11, which matches the saved URL before any fill; no known-phishing list and no warning page", + "state": "decided-no", + "evidence": "Decided no on 2026-09-29: Single competitor, changelog signal only, no demand row. Before: searched 'phish', 'blocklist' in browser-extension/ src/ lib/: only browser-extension/src/lib/match.js:11, which matches the saved URL before any fill; no known-phishing list and no warning page", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", "note": "The extension refuses to fill on a site whose address does not match the saved login, which blunts phishing, but it never warns about a known phishing site." diff --git a/openspec/parity/gap-decisions.json b/openspec/parity/gap-decisions.json index 4dfc50840..9ef769895 100644 --- a/openspec/parity/gap-decisions.json +++ b/openspec/parity/gap-decisions.json @@ -726,5 +726,237 @@ "reason": "Build: the core area (vault, the area of the first 30 rows) with a changelog row (https://github.com/marius-wieschollek/passwords/blob/2026.7.0/CHANGELOG.md). Specified for the missing half: a preview image of the site; site icons are built.", "change": "vault-website-addresses", "decidedOn": "2026-09-27" + }, + { + "row": "vault-11", + "matrix": "keepiq", + "decision": "build", + "reason": "Core area (vault) and six competitors rate yes; the missing half is a hidden field kind.", + "change": "vault-custom-field-kinds-and-ssh-key", + "decidedOn": "2026-09-29" + }, + { + "row": "vault-14", + "matrix": "keepiq", + "decision": "build", + "reason": "Core area (vault) and three competitors rate yes; the missing half is the key pair fields and generation.", + "change": "vault-custom-field-kinds-and-ssh-key", + "decidedOn": "2026-09-29" + }, + { + "row": "vault-20", + "matrix": "keepiq", + "decision": "build", + "reason": "Core area (vault); the backend stores both preferences and no screen edits or reads them.", + "change": "vault-defaults-and-recently-used-widget", + "decidedOn": "2026-09-29" + }, + { + "row": "vault-21", + "matrix": "keepiq", + "decision": "build", + "reason": "Core area (vault); the recently-accessed query is finished and has no caller.", + "change": "vault-defaults-and-recently-used-widget", + "decidedOn": "2026-09-29" + }, + { + "row": "crypto-06", + "matrix": "keepiq", + "decision": "build", + "reason": "Four competitors rate yes; the web auto-lock is a timer from unlock and updateActivity has no caller.", + "change": "crypto-session-timeout-and-inactivity-lock", + "decidedOn": "2026-09-29" + }, + { + "row": "crypto-07", + "matrix": "keepiq", + "decision": "build", + "reason": "Four competitors rate yes; the timeout choice is not saved and Nextcloud session becomes 10 minutes.", + "change": "crypto-session-timeout-and-inactivity-lock", + "decidedOn": "2026-09-29" + }, + { + "row": "sharing-02", + "matrix": "keepiq", + "decision": "build", + "reason": "Five competitors rate yes; backend is complete and nothing opens the form.", + "change": "sharing-group-share-entry-point", + "decidedOn": "2026-09-29" + }, + { + "row": "admin-18", + "matrix": "keepiq", + "decision": "build", + "reason": "Feature request row plus one competitor yes (Keeper); no page creates a type and a type has no field list.", + "change": "admin-secret-type-editor", + "decidedOn": "2026-09-29" + }, + { + "row": "portability-03", + "matrix": "keepiq", + "decision": "build", + "reason": "Two competitors rate yes; the parser supports a mapping the wizard never passes.", + "change": "portability-import-field-mapping", + "decidedOn": "2026-09-29" + }, + { + "row": "portability-08", + "matrix": "keepiq", + "decision": "build", + "reason": "Two competitors rate yes; the handshake only works between two Keepiq sessions on one server.", + "change": "portability-cxp-cross-provider-transfer", + "decidedOn": "2026-09-29" + }, + { + "row": "clients-03", + "matrix": "keepiq", + "decision": "build", + "reason": "Five competitors rate yes; the offer only appears in the popup and never updates an existing login.", + "change": "clients-extension-save-prompt-and-passkey-origin", + "decidedOn": "2026-09-29" + }, + { + "row": "clients-05", + "matrix": "keepiq", + "decision": "build", + "reason": "Three competitors rate yes; the relay trusts a page-supplied origin.", + "change": "clients-extension-save-prompt-and-passkey-origin", + "decidedOn": "2026-09-29" + }, + { + "row": "clients-12", + "matrix": "keepiq", + "decision": "build", + "reason": "Four competitors rate yes; the Firefox build probably does not start and there is no Safari build.", + "change": "clients-extension-firefox-and-safari-builds", + "decidedOn": "2026-09-29" + }, + { + "row": "sharing-11", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "No competitor rated yes, no demand, outside the core area. Reversible: the reclaim half is built and the creation half is a store and dialog gap", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "sharing-13", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "Single competitor, no demand", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "sharing-23", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "Single competitor, no demand", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "apps-13", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "No competitor rated yes, no demand, outside the core area. Reversible: ApplicationService::delete cascades leases and lease policies but not secrets", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "apps-19", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "Single competitor, no demand", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "apps-24", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "Single competitor, no demand", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "pki-02", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "No competitor rated yes, no demand, outside the core area. Reversible: the intermediate renews on a daily job and the root renews only by hand", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "pki-07", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "Single competitor, no demand", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "pki-09", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "No competitor rated yes, no demand, outside the core area. Note: PrivateKeyDownloadDialog is mounted but no PHP code returns a private_key, so the dialog is dead UI", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "health-09", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "Single competitor, no demand", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "health-10", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "Single competitor, no demand", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "rotation-07", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "No competitor rated yes, no demand, outside the core area. Reversible: the expiry policy routes and store actions exist and have no page", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "admin-17", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "No competitor rated yes, no demand, outside the core area. Reversible: the CA health summary is computed and no widget renders it", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "admin-24", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "Single competitor, changelog signal only, no demand row", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "audit-16", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "Single competitor, changelog signal only, no demand row", + "change": null, + "decidedOn": "2026-09-29" + }, + { + "row": "clients-22", + "matrix": "keepiq", + "decision": "decided-no", + "reason": "Single competitor, changelog signal only, no demand row", + "change": null, + "decidedOn": "2026-09-29" } ] From 310c643092245d4855f7042054c081a78a7453b1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 21:36:27 +0200 Subject: [PATCH 002/245] test(applications): assert no private-key state or dialog, since the server never returns a key --- src/dialogs/PrivateKeyDownloadDialog.vue | 253 ------------------ .../dialogs/PrivateKeyDownloadDialog.spec.js | 159 ----------- tests/store/application.spec.js | 35 +-- tests/views/AdminApplicationsView.spec.js | 45 +--- tests/views/ApplicationRegisterView.spec.js | 15 +- 5 files changed, 20 insertions(+), 487 deletions(-) delete mode 100644 src/dialogs/PrivateKeyDownloadDialog.vue delete mode 100644 tests/dialogs/PrivateKeyDownloadDialog.spec.js diff --git a/src/dialogs/PrivateKeyDownloadDialog.vue b/src/dialogs/PrivateKeyDownloadDialog.vue deleted file mode 100644 index d6bcbf29f..000000000 --- a/src/dialogs/PrivateKeyDownloadDialog.vue +++ /dev/null @@ -1,253 +0,0 @@ - - @@ -316,7 +319,9 @@ import KeyIcon from 'vue-material-design-icons/Key.vue' import LockIcon from 'vue-material-design-icons/Lock.vue' import LockOpenVariantIcon from 'vue-material-design-icons/LockOpenVariant.vue' import DeviceApprovalRequest from '../components/DeviceApprovalRequest.vue' +import ForgotPasswordRecovery from '../components/ForgotPasswordRecovery.vue' import PasswordStrengthMeter from '../components/PasswordStrengthMeter.vue' +import { useAccountRecoveryStore } from '../store/modules/accountRecovery.js' import { useDeviceApprovalStore } from '../store/modules/deviceApproval.js' import { useEncryptionSuiteStore } from '../store/modules/encryptionSuite.js' import { useOfflineStore } from '../store/modules/offline.js' @@ -388,6 +393,7 @@ export default { KeyIcon, PasswordStrengthMeter, DeviceApprovalRequest, + ForgotPasswordRecovery, }, data() { @@ -726,6 +732,57 @@ export default { * @return {Promise} * @spec openspec/changes/crypto-new-device-approval/specs/new-device-approval/spec.md#requirement-pickup-is-one-time-and-unlocks-one-session */ + /** + * Enrol in account recovery while the master password is in hand, + * when the policy requires it or an enrolment fell behind a key or + * suite rotation, and say so (crypto-organisation-account-recovery D5). + * + * @param {string} masterPassword The password just used to unlock. + * @return {Promise} + * @spec openspec/changes/crypto-organisation-account-recovery/specs/organisation-account-recovery/spec.md#requirement-users-enrol-by-wrapping-their-own-key-to-the-recovery-certificate + */ + async enrolForRecovery(masterPassword) { + const store = useAccountRecoveryStore() + if ((await store.enrolAtUnlock(masterPassword)) !== 'enrolled') { + return + } + const { showSuccess } = await import('@nextcloud/dialogs') + showSuccess( + t( + 'keepiq', + 'You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}', + { + fingerprint: store.status?.key?.fingerprint ?? '', + }, + ), + ) + }, + + /** + * Recovery is done and the vault is unlocked under the new master + * password: say who handled it and offer a key rotation (D4). + * + * @param {string} handledBy The officer who handed the key over. + * @return {Promise} + * @spec openspec/changes/crypto-organisation-account-recovery/specs/organisation-account-recovery/spec.md#requirement-the-user-is-told-what-happened-and-offered-a-rotation + */ + async onRecovered(handledBy) { + // Tell the user who handled it and offer a key rotation (D4). + const { showSuccess } = await import('@nextcloud/dialogs') + showSuccess( + t( + 'keepiq', + 'Recovered with help from {officer}. Rotate your vault key now in Settings, Security: "My master password was compromised".', + { officer: handledBy || t('keepiq', 'a recovery officer') }, + ), + { timeout: -1 }, + ) + await this.onApprovedUnlock() + }, + + /** + * @spec openspec/changes/crypto-new-device-approval/specs/new-device-approval/spec.md#requirement-pickup-is-one-time-and-unlocks-one-session + */ async onApprovedUnlock() { const returnUrl = this.$route.query.returnUrl || '/' await this.playUnlockAnimation() @@ -777,6 +834,7 @@ export default { try { if (this.offlineStore.online) { await this.sessionStore.unlock(this.masterPassword) + await this.enrolForRecovery(this.masterPassword) } else { // Offline unlock from the cached snapshot — no server request; // the master password never leaves the browser (offline §4.1). diff --git a/src/views/settings/Settings.vue b/src/views/settings/Settings.vue index 7c69bfa80..6f2fe4531 100644 --- a/src/views/settings/Settings.vue +++ b/src/views/settings/Settings.vue @@ -32,6 +32,7 @@ + @@ -43,6 +44,7 @@ + + diff --git a/src/store/modules/delegation.js b/src/store/modules/delegation.js index c4ca70f4f..0370437ba 100644 --- a/src/store/modules/delegation.js +++ b/src/store/modules/delegation.js @@ -13,8 +13,10 @@ */ import axios from '@nextcloud/axios' +import { translate as t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { defineStore } from 'pinia' +import { PROOF_PURPOSE, sessionKeyProofHeaders } from '../../crypto/keyProof.js' export const useDelegationStore = defineStore('delegation', { state: () => ({ @@ -108,11 +110,21 @@ export const useDelegationStore = defineStore('delegation', { this.loading = true this.error = null try { + // Every delegation needs a vault-key proof (keepiq#818). + const { headers } = await sessionKeyProofHeaders({ + purpose: PROOF_PURPOSE.DELEGATION_CREATE, + reason: t( + 'keepiq', + 'Enter your master password to confirm this delegation.', + ), + boundValues: [secretId, delegatedTo], + }) const response = await axios.post( generateUrl( `/apps/keepiq/api/v1/secrets/${secretId}/delegations`, ), { delegatedTo }, + { headers }, ) this.delegations.push(response.data) return response.data @@ -167,10 +179,22 @@ export const useDelegationStore = defineStore('delegation', { this.loading = true this.error = null try { + // A handover creates a delegation too, so it needs a vault-key + // proof (keepiq#818). + const { headers } = await sessionKeyProofHeaders({ + purpose: PROOF_PURPOSE.DELEGATION_HANDOVER, + reason: t( + 'keepiq', + 'Enter your master password to confirm this delegation.', + ), + boundValues: [secretId], + }) const response = await axios.post( generateUrl( `/apps/keepiq/api/v1/secrets/${secretId}/delegations/handover`, ), + {}, + { headers }, ) this.delegations.push(response.data) return response.data diff --git a/src/store/modules/groupShare.js b/src/store/modules/groupShare.js index 4ab005901..7b9640712 100644 --- a/src/store/modules/groupShare.js +++ b/src/store/modules/groupShare.js @@ -134,13 +134,8 @@ export const useGroupShareStore = defineStore('groupShare', { groupShare.id, members, ) - const response = await axios.post( - generateUrl('/apps/keepiq/api/v1/shares/register-batch'), - { shares: rows }, - ) - const items = Array.isArray(response.data?.items) - ? response.data.items - : [] + // register-batch needs a vault-key proof (keepiq#818). + const { items } = await useShareStore().registerBatch(rows) received = items.filter( (item) => item.status === 'created' || item.status === 'exists', diff --git a/src/store/modules/keyProofPrompt.js b/src/store/modules/keyProofPrompt.js new file mode 100644 index 000000000..02418d555 --- /dev/null +++ b/src/store/modules/keyProofPrompt.js @@ -0,0 +1,125 @@ +/** + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * The app-wide master password prompt for vault-key proofs (keepiq#818). + * + * Sharing with a new recipient, registering a batch of shares and creating a + * delegation need a vault-key proof. Those requests start in stores and + * dialogs all over the app, so instead of a password field in each one, a + * caller awaits `ask(reason)` and the single KeyProofPromptDialog in the app + * shell collects the password. The prompt checks the password against the + * session's private key envelope before it resolves, so a typo is caught in + * the dialog rather than as a refused request. The password is handed to the + * caller and never stored here after the prompt closes. + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + +import { translate as t } from '@nextcloud/l10n' +import { defineStore } from 'pinia' +import { decryptPrivateKey } from '../../crypto/index.js' +import { useSessionStore } from './session.js' + +/** + * Error thrown to the caller when the user closes the prompt. + */ +export class KeyProofPromptCancelled extends Error { + /** + * A cancelled prompt, with a stable code callers can test for. + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + constructor() { + super('Master password prompt cancelled') + this.name = 'KeyProofPromptCancelled' + this.code = 'key_proof_cancelled' + } +} + +let pending = null + +export const useKeyProofPromptStore = defineStore('keyProofPrompt', { + state: () => ({ + /** @type {boolean} Whether the prompt is shown. */ + open: false, + /** @type {string} Why the password is asked, shown in the prompt. */ + reason: '', + /** @type {boolean} Whether the entered password is being checked. */ + checking: false, + /** @type {string} The last check failure, or empty. */ + error: '', + }), + + actions: { + /** + * Ask for the master password. Resolves with it once it opens the + * session's private key envelope; rejects with KeyProofPromptCancelled + * when the user closes the prompt. A second ask while one is open + * cancels the first. + * + * @param {string} reason Why the password is needed, in the user's words. + * @return {Promise} The master password. + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + ask(reason) { + if (pending !== null) { + pending.reject(new KeyProofPromptCancelled()) + } + this.reason = reason + this.error = '' + this.checking = false + this.open = true + return new Promise((resolve, reject) => { + pending = { resolve, reject } + }) + }, + + /** + * Check the entered password and resolve the pending ask with it. + * + * @param {string} masterPassword The entered password. + * @return {Promise} Whether the password was accepted. + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + async submit(masterPassword) { + if (pending === null || !masterPassword) { + return false + } + this.checking = true + this.error = '' + try { + await decryptPrivateKey( + useSessionStore().encryptedPrivateKey, + masterPassword, + ) + } catch { + this.error = t('keepiq', 'That master password is not right.') + return false + } finally { + this.checking = false + } + const { resolve } = pending + pending = null + this.open = false + resolve(masterPassword) + return true + }, + + /** + * Close the prompt and reject the pending ask. + * + * @return {void} + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + cancel() { + this.open = false + this.error = '' + if (pending !== null) { + const { reject } = pending + pending = null + reject(new KeyProofPromptCancelled()) + } + }, + }, +}) diff --git a/src/store/modules/share.js b/src/store/modules/share.js index 3764f1eb2..83bd7e086 100644 --- a/src/store/modules/share.js +++ b/src/store/modules/share.js @@ -18,9 +18,11 @@ */ import axios from '@nextcloud/axios' +import { translate as t } from '@nextcloud/l10n' import { generateOcsUrl, generateUrl } from '@nextcloud/router' import { defineStore } from 'pinia' import { importPublicKey, rsaEncrypt } from '../../crypto/index.js' +import { PROOF_PURPOSE, sessionKeyProofHeaders } from '../../crypto/keyProof.js' /** * How many candidates one shareability probe may name. @@ -307,11 +309,29 @@ export const useShareStore = defineStore('share', { ) { this.loading = true this.error = null + const url = generateUrl(`/apps/keepiq/api/v1/secrets/${secretId}/shares`) + const body = { targetUserId, recipientSecretId, groupShareId } try { - const response = await axios.post( - generateUrl(`/apps/keepiq/api/v1/secrets/${secretId}/shares`), - { targetUserId, recipientSecretId, groupShareId }, - ) + let response + try { + response = await axios.post(url, body) + } catch (refusal) { + // A share to someone the user does not share with yet needs a + // vault-key proof (keepiq#818); a known recipient does not, so + // ask for the password only when the server says so. + if (refusal?.response?.data?.error !== 'key_proof_required') { + throw refusal + } + const { headers } = await sessionKeyProofHeaders({ + purpose: PROOF_PURPOSE.SHARE_NEW_RECIPIENT, + reason: t( + 'keepiq', + 'You are sharing with someone new. Enter your master password to confirm.', + ), + boundValues: [secretId, targetUserId], + }) + response = await axios.post(url, body, { headers }) + } this.shares.push(response.data) return response.data } catch (e) { @@ -447,6 +467,41 @@ export const useShareStore = defineStore('share', { } }, + /** + * Register direct share rows through `/shares/register-batch`, which + * needs a vault-key proof on every call (keepiq#818). The master + * password is asked through the app-wide prompt unless the caller + * already has it from earlier in the same action; it is returned so a + * bulk run asks once and builds a fresh single-use proof per request. + * + * @param {Array} rows The register-batch rows. + * @param {object} [options] Options. + * @param {string} [options.masterPassword] A password asked for earlier in this action. + * @return {Promise<{items: Array, masterPassword: string}>} + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + async registerBatch(rows, { masterPassword = '' } = {}) { + const proof = await sessionKeyProofHeaders({ + purpose: PROOF_PURPOSE.SHARE_REGISTER_BATCH, + reason: t( + 'keepiq', + 'Enter your master password to confirm this share.', + ), + masterPassword, + }) + const response = await axios.post( + generateUrl('/apps/keepiq/api/v1/shares/register-batch'), + { shares: rows }, + { headers: proof.headers }, + ) + return { + items: Array.isArray(response.data?.items) + ? response.data.items + : [], + masterPassword: proof.masterPassword, + } + }, + /** * Create a batch of recipient share targets (group-share expansion). * diff --git a/src/store/modules/shareApproval.js b/src/store/modules/shareApproval.js index 533410642..affdfce16 100644 --- a/src/store/modules/shareApproval.js +++ b/src/store/modules/shareApproval.js @@ -7,6 +7,7 @@ import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import { defineStore } from 'pinia' import { useGroupShareStore } from './groupShare.js' +import { useShareStore } from './share.js' /** register-batch statuses that mean the recipient now holds a copy. */ const SHARED = ['created', 'exists'] @@ -52,11 +53,9 @@ export const useShareApprovalStore = defineStore('shareApproval', { groupShareId, [{ userId, certificate: recipient.certificate }], ) - const response = await axios.post( - generateUrl('/apps/keepiq/api/v1/shares/register-batch'), - { shares: rows }, - ) - return String(response.data?.items?.[0]?.status ?? 'not_registered') + // register-batch needs a vault-key proof (keepiq#818). + const { items } = await useShareStore().registerBatch(rows) + return String(items[0]?.status ?? 'not_registered') }, /** diff --git a/tests/Unit/Controller/SharingKeyProofGuardTest.php b/tests/Unit/Controller/SharingKeyProofGuardTest.php new file mode 100644 index 000000000..c9e63a583 --- /dev/null +++ b/tests/Unit/Controller/SharingKeyProofGuardTest.php @@ -0,0 +1,286 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Tests\Unit\Controller; + +use OCA\Keepiq\Controller\DelegationController; +use OCA\Keepiq\Controller\ShareController; +use OCA\Keepiq\Db\ShareTarget; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Db\SuiteMigrationMapper; +use OCA\Keepiq\Db\UsedProofNonceMapper; +use OCA\Keepiq\Exception\KeyProofRequiredException; +use OCA\Keepiq\Middleware\VaultKeyProofMiddleware; +use OCA\Keepiq\Service\DelegationService; +use OCA\Keepiq\Service\EncryptionSuiteService; +use OCA\Keepiq\Service\KnownShareRecipientExemption; +use OCA\Keepiq\Service\ShareService; +use OCA\Keepiq\Service\VaultKeyProofService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Tests for the sharing and delegation vault-key proofs (keepiq#818). + */ +class SharingKeyProofGuardTest extends TestCase { + private IRequest $request; + private IUserSession $userSession; + private ShareTargetMapper $shareTargets; + private ShareService $shareService; + private DelegationService $delegationService; + private VaultKeyProofMiddleware $middleware; + + /** + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + // No proof headers: what a stolen session sends. + $this->request->method('getHeader')->willReturn(''); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession = $this->createMock(IUserSession::class); + $this->userSession->method('getUser')->willReturn($user); + + $this->shareTargets = $this->createMock(ShareTargetMapper::class); + $this->shareService = $this->createMock(ShareService::class); + $this->delegationService = $this->createMock(DelegationService::class); + + $exemption = new KnownShareRecipientExemption(shareTargets: $this->shareTargets); + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([[KnownShareRecipientExemption::class, $exemption]]); + + $suiteService = $this->createMock(EncryptionSuiteService::class); + $suite = new \OCA\Keepiq\Db\EncryptionSuite(); + $suite->setOwnerType('user'); + $suite->setOwnerId('alice'); + $suite->setCertificate('CERT-PEM'); + $suiteService->method('getActiveSuite')->willReturn($suite); + + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString')->willReturn('instance-secret'); + + $this->middleware = new VaultKeyProofMiddleware( + request: $this->request, + userSession: $this->userSession, + suiteService: $suiteService, + proofService: new VaultKeyProofService( + config: $config, + secureRandom: $this->createMock(ISecureRandom::class), + timeFactory: $this->createMock(ITimeFactory::class), + usedNonces: $this->createMock(UsedProofNonceMapper::class), + logger: $this->createMock(LoggerInterface::class), + ), + migrationMapper: $this->createMock(SuiteMigrationMapper::class), + logger: $this->createMock(LoggerInterface::class), + container: $container, + ); + }//end setUp() + + /** + * Run one request the way the framework does. + * + * @param Controller $controller The controller + * @param string $method The method + * @param array $args The named arguments + * + * @return JSONResponse + */ + private function dispatch(Controller $controller, string $method, array $args): JSONResponse { + try { + $this->middleware->beforeController($controller, $method); + } catch (KeyProofRequiredException $e) { + return $this->middleware->afterException($controller, $method, $e); + } + + return $controller->$method(...$args); + }//end dispatch() + + /** + * @return ShareController + */ + private function shareController(): ShareController { + return new ShareController( + request: $this->request, + shareService: $this->shareService, + userSession: $this->userSession, + ); + }//end shareController() + + /** + * @return DelegationController + */ + private function delegationController(): DelegationController { + return new DelegationController( + request: $this->request, + delegationService: $this->delegationService, + userSession: $this->userSession, + ); + }//end delegationController() + + /** + * Stub the request parameters. + * + * @param array $params Name => value + * + * @return void + */ + private function params(array $params): void { + $this->request->method('getParam')->willReturnCallback( + static fn (string $name, $default = null) => ($params[$name] ?? $default) + ); + }//end params() + + /** + * A direct share to a recipient alice has never shared with is refused + * without a proof, and nothing is shared. + * + * @return void + */ + public function testAShareToANewRecipientWithoutAProofIsRefused(): void { + $this->params(['secretId' => 'sec-1', 'targetUserId' => 'mallory']); + $this->shareTargets->method('hasDirectShareBetween')->with('alice', 'mallory')->willReturn(false); + $this->shareService->expects($this->never())->method('createShare'); + + $response = $this->dispatch( + $this->shareController(), + 'create', + ['secretId' => 'sec-1', 'targetUserId' => 'mallory', 'recipientSecretId' => 'copy-1'] + ); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + $this->assertSame('key_proof_required', $response->getData()['error']); + }//end testAShareToANewRecipientWithoutAProofIsRefused() + + /** + * A direct share to a recipient alice already shares with directly needs + * no proof: ordinary sharing keeps working on a session. + * + * @return void + */ + public function testAShareToAKnownRecipientNeedsNoProof(): void { + $this->params(['secretId' => 'sec-2', 'targetUserId' => 'bob']); + $this->shareTargets->method('hasDirectShareBetween')->with('alice', 'bob')->willReturn(true); + $row = new ShareTarget(); + $this->shareService->expects($this->once())->method('createShare')->willReturn($row); + + $response = $this->dispatch( + $this->shareController(), + 'create', + ['secretId' => 'sec-2', 'targetUserId' => 'bob', 'recipientSecretId' => 'copy-2'] + ); + + $this->assertSame(Http::STATUS_CREATED, $response->getStatus()); + }//end testAShareToAKnownRecipientNeedsNoProof() + + /** + * A failing recipient lookup does not waive the proof. + * + * @return void + */ + public function testAFailingRecipientLookupStillNeedsAProof(): void { + $this->params(['secretId' => 'sec-1', 'targetUserId' => 'bob']); + $this->shareTargets->method('hasDirectShareBetween')->willThrowException(new \RuntimeException('db down')); + $this->shareService->expects($this->never())->method('createShare'); + + $response = $this->dispatch( + $this->shareController(), + 'create', + ['secretId' => 'sec-1', 'targetUserId' => 'bob', 'recipientSecretId' => 'copy-1'] + ); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + }//end testAFailingRecipientLookupStillNeedsAProof() + + /** + * A batch registration is refused without a proof, even to known recipients. + * + * @return void + */ + public function testABatchRegistrationWithoutAProofIsRefused(): void { + $this->params([]); + $this->shareTargets->method('hasDirectShareBetween')->willReturn(true); + $this->shareService->expects($this->never())->method('registerDirectShares'); + + $response = $this->dispatch( + $this->shareController(), + 'registerBatch', + ['shares' => [['sourceSecretId' => 'sec-1', 'targetUserId' => 'bob', 'encryptedKey' => 'x']]] + ); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + }//end testABatchRegistrationWithoutAProofIsRefused() + + /** + * Creating a delegation is refused without a proof. + * + * @return void + */ + public function testADelegationWithoutAProofIsRefused(): void { + $this->params(['secretId' => 'sec-1', 'delegatedTo' => 'bob']); + $this->delegationService->expects($this->never())->method('createDelegation'); + + $response = $this->dispatch( + $this->delegationController(), + 'create', + ['secretId' => 'sec-1', 'delegatedTo' => 'bob'] + ); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + }//end testADelegationWithoutAProofIsRefused() + + /** + * The admin handover creates a delegation too, and is refused without a proof. + * + * @return void + */ + public function testAnAdminHandoverWithoutAProofIsRefused(): void { + $this->params(['secretId' => 'sec-1']); + $this->delegationService->expects($this->never())->method('createAdminHandover'); + + $response = $this->dispatch( + $this->delegationController(), + 'handover', + ['secretId' => 'sec-1'] + ); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + }//end testAnAdminHandoverWithoutAProofIsRefused() +}//end class diff --git a/tests/Unit/Controller/VaultKeyProofAttributesTest.php b/tests/Unit/Controller/VaultKeyProofAttributesTest.php index eb01a8e8c..83a7ee3a3 100644 --- a/tests/Unit/Controller/VaultKeyProofAttributesTest.php +++ b/tests/Unit/Controller/VaultKeyProofAttributesTest.php @@ -32,11 +32,15 @@ namespace OCA\Keepiq\Tests\Unit\Controller; use OCA\Keepiq\Attribute\VaultKeyProofRequired; +use OCA\Keepiq\Controller\DelegationController; use OCA\Keepiq\Controller\DeviceApprovalController; use OCA\Keepiq\Controller\EmergencyAccessController; use OCA\Keepiq\Controller\EncryptionSuiteController; use OCA\Keepiq\Controller\GdprController; use OCA\Keepiq\Controller\MigrationController; +use OCA\Keepiq\Controller\ShareController; +use OCA\Keepiq\Service\KnownShareRecipientExemption; +use OCA\Keepiq\Service\VaultKeyProofExemption; use OCA\Keepiq\Controller\RecoveryOfficerController; use OCA\Keepiq\Service\VaultKeyProofService; use PHPUnit\Framework\TestCase; @@ -117,6 +121,37 @@ public static function guardedMethodsProvider(): array { 'migrationNewSuite', VaultKeyProofService::PURPOSE_ABORT_MIGRATION, ], + // keepiq#818: a session alone must not add a new party who then + // receives every later value of a secret. A share to a recipient + // the caller already shares with is waived by the exemption. + 'share to a new recipient' => [ + ShareController::class, + 'create', + ['secretId', 'targetUserId'], + 'active', + VaultKeyProofService::PURPOSE_SHARE_NEW_RECIPIENT, + ], + 'register a batch of shares' => [ + ShareController::class, + 'registerBatch', + [], + 'active', + VaultKeyProofService::PURPOSE_SHARE_REGISTER_BATCH, + ], + 'create a delegation' => [ + DelegationController::class, + 'create', + ['secretId', 'delegatedTo'], + 'active', + VaultKeyProofService::PURPOSE_DELEGATION_CREATE, + ], + 'admin handover delegation' => [ + DelegationController::class, + 'handover', + ['secretId'], + 'active', + VaultKeyProofService::PURPOSE_DELEGATION_HANDOVER, + ], // Wipes every secret, suite and migration the user has. 'delete account data' => [ GdprController::class, @@ -187,6 +222,33 @@ public function testDestructiveMethodCarriesTheGuard( ); }//end testDestructiveMethodCarriesTheGuard() + /** + * Only the share-to-a-new-recipient guard carries an exemption, and it is + * the known-recipient one (keepiq#818). Every other guard has none: an + * exemption on a destructive route would be a missing guard. + * + * @return void + */ + public function testOnlyTheShareCreateGuardIsExemptable(): void { + foreach (self::guardedMethodsProvider() as $label => [$class, $method]) { + $attribute = (new ReflectionMethod($class, $method)) + ->getAttributes(VaultKeyProofRequired::class)[0] + ->newInstance(); + $expected = ''; + if ($class === ShareController::class && $method === 'create') { + $expected = KnownShareRecipientExemption::class; + } + + $this->assertSame($expected, $attribute->getExemption(), "$label exemption"); + } + + $this->assertContains( + VaultKeyProofExemption::class, + class_implements(KnownShareRecipientExemption::class), + 'the exemption must implement VaultKeyProofExemption, or the middleware ignores it and always asks for a proof' + ); + }//end testOnlyTheShareCreateGuardIsExemptable() + /** * Methods deliberately NOT guarded, with the reason each is safe. * diff --git a/tests/Unit/Middleware/VaultKeyProofMiddlewareTest.php b/tests/Unit/Middleware/VaultKeyProofMiddlewareTest.php index f7325d9d3..631c14906 100644 --- a/tests/Unit/Middleware/VaultKeyProofMiddlewareTest.php +++ b/tests/Unit/Middleware/VaultKeyProofMiddlewareTest.php @@ -65,6 +65,10 @@ public function guardedMigrationOldSuite(): void { public function guardedMigrationNewSuite(): void { } + #[VaultKeyProofRequired(purpose: 'share-new-recipient', exemption: \OCA\Keepiq\Service\KnownShareRecipientExemption::class)] + public function guardedExemptable(): void { + } + public function unguarded(): void { } }//end class @@ -288,6 +292,46 @@ static function (object $event) use (&$dispatched): void { ); }//end testAfterExceptionRecordsAKeyProofRefusedAuditEvent() + /** + * Without a container the declared exemption cannot be asked, so the proof + * is still required (keepiq#818 fails closed). + * + * @return void + */ + public function testAnExemptionWithoutAContainerStillRequiresTheProof(): void { + $this->suiteService->method('getActiveSuite')->willReturn($this->suiteWithCertificate('CERT-PEM')); + $this->request->method('getHeader')->willReturn(''); + $this->proofService->expects($this->once())->method('verify') + ->willThrowException(new KeyProofRequiredException('No challenge presented')); + + $this->expectException(KeyProofRequiredException::class); + $this->middleware->beforeController($this->controller, 'guardedExemptable'); + }//end testAnExemptionWithoutAContainerStillRequiresTheProof() + + /** + * A container entry that is not a VaultKeyProofExemption waives nothing. + * + * @return void + */ + public function testAnExemptionOfTheWrongTypeStillRequiresTheProof(): void { + $container = $this->createMock(\Psr\Container\ContainerInterface::class); + $container->method('get')->willReturn(new \stdClass()); + $middleware = new VaultKeyProofMiddleware( + request: $this->request, + userSession: $this->userSession, + suiteService: $this->suiteService, + proofService: $this->proofService, + migrationMapper: $this->migrationMapper, + logger: $this->logger, + container: $container, + ); + $this->suiteService->method('getActiveSuite')->willReturn($this->suiteWithCertificate('CERT-PEM')); + $this->request->method('getHeader')->willReturn(''); + $this->proofService->expects($this->once())->method('verify'); + + $middleware->beforeController($this->controller, 'guardedExemptable'); + }//end testAnExemptionOfTheWrongTypeStillRequiresTheProof() + public function testAfterExceptionRethrowsAForeignException(): void { $this->expectException(RuntimeException::class); $this->middleware->afterException( diff --git a/tests/components/DelegationManager.spec.js b/tests/components/DelegationManager.spec.js index 901613dd2..5edf94523 100644 --- a/tests/components/DelegationManager.spec.js +++ b/tests/components/DelegationManager.spec.js @@ -13,6 +13,15 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import DelegationManager from '../../src/components/share/DelegationManager.vue' +// keepiq#818: a delegation carries a vault-key proof; the proof is stubbed. +vi.mock('../../src/crypto/keyProof.js', async (importOriginal) => ({ + ...(await importOriginal()), + sessionKeyProofHeaders: vi.fn(async () => ({ + headers: { 'X-Keepiq-Key-Proof': 'proof' }, + masterPassword: 'pw', + })), +})) + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) describe('DelegationManager', () => { @@ -126,6 +135,7 @@ describe('DelegationManager', () => { expect(post).toHaveBeenCalledWith( '/apps/keepiq/api/v1/secrets/sec-1/delegations', { delegatedTo: 'bob' }, + { headers: { 'X-Keepiq-Key-Proof': 'proof' } }, ) expect(wrapper.emitted('delegated')).toBeTruthy() }) diff --git a/tests/dialogs/BulkPhaseDialogs.spec.js b/tests/dialogs/BulkPhaseDialogs.spec.js index c2304b845..864e6934e 100644 --- a/tests/dialogs/BulkPhaseDialogs.spec.js +++ b/tests/dialogs/BulkPhaseDialogs.spec.js @@ -28,9 +28,26 @@ import BulkMoveDialog from '../../src/dialogs/BulkMoveDialog.vue' import BulkShareDialog from '../../src/dialogs/BulkShareDialog.vue' import BulkTeamFolderDialog from '../../src/dialogs/BulkTeamFolderDialog.vue' import { useBulkStore } from '../../src/store/modules/bulk.js' +import { useKeyProofPromptStore } from '../../src/store/modules/keyProofPrompt.js' import { useSecretStore } from '../../src/store/modules/secret.js' +import { useShareStore } from '../../src/store/modules/share.js' import { useTeamFolderStore } from '../../src/store/modules/teamFolder.js' +// Sharing needs a vault-key proof (keepiq#818). The proof itself is built by +// sessionKeyProofHeaders; here it returns a fixed header and echoes the +// password, so the tests can see that each request carried a proof. +vi.mock('../../src/crypto/keyProof.js', async (importOriginal) => ({ + ...(await importOriginal()), + sessionKeyProofHeaders: vi.fn( + async ({ purpose, boundValues, masterPassword }) => ({ + headers: { + 'X-Keepiq-Key-Proof': `proof(${purpose}|${(boundValues ?? []).join(',')})`, + }, + masterPassword: masterPassword || 'from-prompt', + }), + ), +})) + /** The chunked runner awaits per item, so one tick is not enough. */ const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) @@ -125,6 +142,8 @@ describe.each(CASES)( vi.spyOn(axios, 'post').mockResolvedValue({ data: { items: [{ status: 'created' }] }, }) + // Share asks for the master password once before its run (keepiq#818). + vi.spyOn(useKeyProofPromptStore(), 'ask').mockResolvedValue('pw') }) it('asks before the run: input, run button, selection-counted title', async () => { @@ -204,3 +223,57 @@ describe('BulkShareDialog: a refused recipient is not a finished run', () => { expect(title(wrapper)).toBe('Share {count} secrets') }) }) + +// keepiq#818: a bulk share asks for the master password once, sends a proof +// with every register-batch call, and a cancelled prompt starts nothing. +describe('BulkShareDialog: the vault-key proof', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + vi.spyOn(useSecretStore(), 'fetchSecret').mockResolvedValue({ key: 'k' }) + vi.spyOn(axios, 'get').mockResolvedValue({ data: { certificate: 'cert' } }) + vi.spyOn(useShareStore(), 'encryptForRecipient').mockResolvedValue({ + key: 'enc', + }) + }) + + it('asks once and sends a proof with every registration', async () => { + const ask = vi.spyOn(useKeyProofPromptStore(), 'ask').mockResolvedValue('pw') + const post = vi.spyOn(axios, 'post').mockResolvedValue({ + data: { items: [{ status: 'created' }] }, + }) + useBulkStore().setSelection(['a', 'b']) + const wrapper = mountDialog(BulkShareDialog) + await wrapper.setData({ targetUserId: 'bob' }) + await wrapper.find('[data-testid="bulk-share-run"]').trigger('click') + await flushPromises() + + expect(ask).toHaveBeenCalledTimes(1) + const batches = post.mock.calls.filter(([url]) => + url.endsWith('/shares/register-batch'), + ) + expect(batches).toHaveLength(2) + for (const [, , config] of batches) { + expect(config.headers['X-Keepiq-Key-Proof']).toBe( + 'proof(share-register-batch|)', + ) + } + // The password is not kept after the run. + expect(wrapper.vm.runPassword).toBe('') + }) + + it('starts nothing when the password prompt is cancelled', async () => { + vi.spyOn(useKeyProofPromptStore(), 'ask').mockRejectedValue( + new Error('cancelled'), + ) + const post = vi.spyOn(axios, 'post') + useBulkStore().setSelection(['a']) + const wrapper = mountDialog(BulkShareDialog) + await wrapper.setData({ targetUserId: 'bob' }) + await wrapper.find('[data-testid="bulk-share-run"]').trigger('click') + await flushPromises() + + expect(post).not.toHaveBeenCalled() + expect(wrapper.vm.ran).toBe(false) + }) +}) diff --git a/tests/store/delegation.spec.js b/tests/store/delegation.spec.js index 4c3c838d9..e11ed2e0d 100644 --- a/tests/store/delegation.spec.js +++ b/tests/store/delegation.spec.js @@ -12,6 +12,21 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useDelegationStore } from '../../src/store/modules/delegation.js' +// Sharing needs a vault-key proof (keepiq#818). The proof itself is built by +// sessionKeyProofHeaders; here it returns a fixed header and echoes the +// password, so the tests can see that each request carried a proof. +vi.mock('../../src/crypto/keyProof.js', async (importOriginal) => ({ + ...(await importOriginal()), + sessionKeyProofHeaders: vi.fn( + async ({ purpose, boundValues, masterPassword }) => ({ + headers: { + 'X-Keepiq-Key-Proof': `proof(${purpose}|${(boundValues ?? []).join(',')})`, + }, + masterPassword: masterPassword || 'from-prompt', + }), + ), +})) + describe('useDelegationStore', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -54,6 +69,11 @@ describe('useDelegationStore', () => { const store = useDelegationStore() const row = await store.createDelegation('sec-1', 'bob') + // keepiq#818: every delegation carries a proof bound to the + // secret and the delegate. + expect(axios.post.mock.calls[0][2].headers).toEqual({ + 'X-Keepiq-Key-Proof': 'proof(delegation-create|sec-1,bob)', + }) expect(row.id).toBe('d1') expect(store.count).toBe(1) expect(store.delegations[0].delegatedTo).toBe('bob') diff --git a/tests/store/groupShare.spec.js b/tests/store/groupShare.spec.js index 322b58c0b..299033ccb 100644 --- a/tests/store/groupShare.spec.js +++ b/tests/store/groupShare.spec.js @@ -12,6 +12,21 @@ import axios from '@nextcloud/axios' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useGroupShareStore } from '../../src/store/modules/groupShare.js' + +// Sharing needs a vault-key proof (keepiq#818). The proof itself is built by +// sessionKeyProofHeaders; here it returns a fixed header and echoes the +// password, so the tests can see that each request carried a proof. +vi.mock('../../src/crypto/keyProof.js', async (importOriginal) => ({ + ...(await importOriginal()), + sessionKeyProofHeaders: vi.fn( + async ({ purpose, boundValues, masterPassword }) => ({ + headers: { + 'X-Keepiq-Key-Proof': `proof(${purpose}|${(boundValues ?? []).join(',')})`, + }, + masterPassword: masterPassword || 'from-prompt', + }), + ), +})) import { useSecretStore } from '../../src/store/modules/secret.js' import { useShareStore } from '../../src/store/modules/share.js' @@ -82,8 +97,12 @@ describe('useGroupShareStore', () => { { groupId: 'finance' }, ) expect(encrypt).toHaveBeenCalledTimes(3) - const [url, body] = post.mock.calls[1] + const [url, body, config] = post.mock.calls[1] expect(url).toBe('/apps/keepiq/api/v1/shares/register-batch') + // keepiq#818: the batch registration carries a proof. + expect(config.headers).toEqual({ + 'X-Keepiq-Key-Proof': 'proof(share-register-batch|)', + }) expect(body.shares[0]).toEqual({ sourceSecretId: 's-1', targetUserId: 'bob', diff --git a/tests/store/keyProofPrompt.spec.js b/tests/store/keyProofPrompt.spec.js new file mode 100644 index 000000000..3410468ec --- /dev/null +++ b/tests/store/keyProofPrompt.spec.js @@ -0,0 +1,59 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The app-wide master password prompt for vault-key proofs (keepiq#818): it + * resolves only with a password that opens the session's key envelope, keeps + * asking after a wrong one, and rejects when the user cancels. + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + KeyProofPromptCancelled, + useKeyProofPromptStore, +} from '../../src/store/modules/keyProofPrompt.js' +import { useSessionStore } from '../../src/store/modules/session.js' + +vi.mock('../../src/crypto/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + decryptPrivateKey: vi.fn(async (envelope, password) => { + if (password !== 'right') { + throw new Error('OperationError') + } + return 'PEM' + }), +})) + +describe('useKeyProofPromptStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + useSessionStore().encryptedPrivateKey = 'ENVELOPE' + }) + + it('keeps asking after a wrong password and resolves with the right one', async () => { + const prompt = useKeyProofPromptStore() + const asked = prompt.ask('why') + expect(prompt.open).toBe(true) + expect(prompt.reason).toBe('why') + + expect(await prompt.submit('wrong')).toBe(false) + expect(prompt.open).toBe(true) + expect(prompt.error).not.toBe('') + + expect(await prompt.submit('right')).toBe(true) + await expect(asked).resolves.toBe('right') + expect(prompt.open).toBe(false) + }) + + it('rejects the waiting caller when the user cancels', async () => { + const prompt = useKeyProofPromptStore() + const asked = prompt.ask('why') + prompt.cancel() + + await expect(asked).rejects.toBeInstanceOf(KeyProofPromptCancelled) + expect(prompt.open).toBe(false) + }) +}) diff --git a/tests/store/share.spec.js b/tests/store/share.spec.js index e2bbbba18..b5a1d5937 100644 --- a/tests/store/share.spec.js +++ b/tests/store/share.spec.js @@ -17,6 +17,17 @@ vi.mock('../../src/crypto/index.js', () => ({ rsaEncrypt: vi.fn(async (value) => `ENC(${value})`), })) +// keepiq#818: a share to a new recipient is retried with a vault-key proof. +vi.mock('../../src/crypto/keyProof.js', async (importOriginal) => ({ + ...(await importOriginal()), + sessionKeyProofHeaders: vi.fn(async ({ purpose, boundValues }) => ({ + headers: { + 'X-Keepiq-Key-Proof': `proof(${purpose}|${boundValues.join(',')})`, + }, + masterPassword: 'pw', + })), +})) + describe('useShareStore', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -82,6 +93,35 @@ describe('useShareStore', () => { expect(row.id).toBe('s-new') expect(store.shares[0].id).toBe('s-new') }) + + it('retries with a proof when the server asks for one (keepiq#818)', async () => { + const post = vi + .spyOn(axios, 'post') + .mockRejectedValueOnce({ + response: { status: 403, data: { error: 'key_proof_required' } }, + }) + .mockResolvedValueOnce({ data: { id: 's-new' } }) + const store = useShareStore() + const row = await store.createShare('sec-1', 'mallory', 'r1', null) + + expect(row.id).toBe('s-new') + expect(post).toHaveBeenCalledTimes(2) + expect(post.mock.calls[0][2]).toBeUndefined() + expect(post.mock.calls[1][2].headers).toEqual({ + 'X-Keepiq-Key-Proof': 'proof(share-new-recipient|sec-1,mallory)', + }) + }) + + it('does not ask for a proof on any other refusal', async () => { + const post = vi.spyOn(axios, 'post').mockRejectedValue({ + response: { status: 400, data: { message: 'nope' } }, + }) + const store = useShareStore() + await expect( + store.createShare('sec-1', 'mallory', 'r1', null), + ).rejects.toBeTruthy() + expect(post).toHaveBeenCalledTimes(1) + }) }) describe('revokeShare', () => { diff --git a/tests/store/shareApproval.spec.js b/tests/store/shareApproval.spec.js index 7579c4126..40cc0d5bf 100644 --- a/tests/store/shareApproval.spec.js +++ b/tests/store/shareApproval.spec.js @@ -17,6 +17,21 @@ import { useSecretStore } from '../../src/store/modules/secret.js' import { useShareStore } from '../../src/store/modules/share.js' import { useShareApprovalStore } from '../../src/store/modules/shareApproval.js' +// Sharing needs a vault-key proof (keepiq#818). The proof itself is built by +// sessionKeyProofHeaders; here it returns a fixed header and echoes the +// password, so the tests can see that each request carried a proof. +vi.mock('../../src/crypto/keyProof.js', async (importOriginal) => ({ + ...(await importOriginal()), + sessionKeyProofHeaders: vi.fn( + async ({ purpose, boundValues, masterPassword }) => ({ + headers: { + 'X-Keepiq-Key-Proof': `proof(${purpose}|${(boundValues ?? []).join(',')})`, + }, + masterPassword: masterPassword || 'from-prompt', + }), + ), +})) + const REQUEST = { sourceSecretId: 's-1', requesterId: 'bob', targetUserId: 'carol' } /** @@ -75,6 +90,10 @@ describe('useShareApprovalStore', () => { '/apps/keepiq/api/v1/shares/register-batch', '/apps/keepiq/api/v1/share-requests/approve', ]) + // keepiq#818: the batch registration carries a proof. + expect(post.mock.calls[1][2].headers).toEqual({ + 'X-Keepiq-Key-Proof': 'proof(share-register-batch|)', + }) expect(post.mock.calls[1][1].shares[0]).toMatchObject({ sourceSecretId: 's-1', targetUserId: 'carol', From 3402a4d94cc080b5ed3fdff1f2d8c46ba348fc3e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 3 Oct 2026 23:23:16 +0200 Subject: [PATCH 139/245] fix(extension): a passkey's private key stays in the worker (#1002) Opening a passkey sent its decrypted credential, private key included, to the popup, where Edit put it into the form and Clone copied it into a new item. The worker now sends only the site and account of a passkey. The extension refuses to create a passkey or change its key, hides Clone and Send for passkeys, and offers no passkey type for a new item. Name, address, folder and notes stay editable. --- .../src/background/vault-handlers.js | 31 ++++++- browser-extension/src/popup/item-detail.js | 12 ++- browser-extension/src/popup/vault-view.js | 7 +- .../specs/extension-vault/spec.md | 15 +++ tests/extension/popupItemDetail.spec.js | 93 +++++++++++++++++++ 5 files changed, 151 insertions(+), 7 deletions(-) diff --git a/browser-extension/src/background/vault-handlers.js b/browser-extension/src/background/vault-handlers.js index 7049977d1..3b94d6075 100644 --- a/browser-extension/src/background/vault-handlers.js +++ b/browser-extension/src/background/vault-handlers.js @@ -10,6 +10,7 @@ */ import { generateKey } from '../../../src/generator/generator.js' +import { parsePasskey } from '../../../src/passkey/passkey.js' import { deriveAesKeyArgon2id } from '../../../src/crypto/argon2.js' import { aesEncrypt, @@ -156,6 +157,7 @@ export function buildVaultHandlers({ * * @spec openspec/changes/clients-extension-generator-vault-send/specs/extension-vault/spec.md#requirement-item-detail-with-copy-and-reveal * @spec openspec/changes/clients-extension-complete/specs/extension-vault/spec.md#requirement-detail-sections-for-every-kind-of-item + * @spec openspec/changes/clients-extension-complete/specs/extension-vault/spec.md#requirement-a-passkeys-private-key-stays-in-the-worker */ 'vault-item': async (payload) => { const account = await unlockedAccount() @@ -193,7 +195,25 @@ export function buildVaultHandlers({ migrationError: row.migrationError || null, } } - const { login, secret } = await vault.decryptSecret(account.id, row) + const decrypted = await vault.decryptSecret(account.id, row) + const login = decrypted.login + let secret = decrypted.secret + // A passkey's private key stays in the worker: the popup gets only + // what it shows, so the key never reaches a page or its DOM. + let passkey + if (meta.typeName === 'passkey') { + const credential = parsePasskey(secret) + passkey = credential + ? { + rpId: credential.rpId, + rpName: credential.rpName, + userName: credential.userName, + userDisplayName: credential.userDisplayName, + createdAt: credential.createdAt, + } + : null + secret = '' + } let additionalFields = null let additionalFieldsError = false if (row.additionalFields) { @@ -223,6 +243,7 @@ export function buildVaultHandlers({ fromCache, login, secret, + ...(passkey !== undefined ? { passkey } : {}), additionalFields, additionalFieldsError, } @@ -234,11 +255,19 @@ export function buildVaultHandlers({ * * @spec openspec/changes/clients-extension-generator-vault-send/specs/extension-vault/spec.md#requirement-add-edit-and-delete-items * @spec openspec/changes/clients-extension-complete/specs/extension-vault/spec.md#requirement-edit-every-kind-of-item + * @spec openspec/changes/clients-extension-complete/specs/extension-vault/spec.md#requirement-a-passkeys-private-key-stays-in-the-worker */ 'vault-save': async (payload) => { const account = await unlockedAccount() const changes = payload.changes || {} const creating = !payload.id + // A passkey is made and updated by the website that uses it; here + // only its name, address, folder and notes change. + if (payload.typeName === 'passkey' && (creating || 'key' in changes)) { + throw new Error( + 'A passkey is created by the website that uses it, and its key cannot be edited', + ) + } if (creating || 'name' in changes) { const name = String(changes.name ?? '').trim() if (name === '') { diff --git a/browser-extension/src/popup/item-detail.js b/browser-extension/src/popup/item-detail.js index 8956f4246..a5b89fb51 100644 --- a/browser-extension/src/popup/item-detail.js +++ b/browser-extension/src/popup/item-detail.js @@ -1,10 +1,11 @@ /** * The item detail view: sections for every kind of item. Values arrive * decrypted from the worker when the item opens and live in this view only; - * `clearDetail` drops them when the view closes. A passkey's private key is - * never shown or copyable. + * `clearDetail` drops them when the view closes. A passkey's private key + * never reaches this view: the worker sends only the site and account. * * @spec openspec/changes/clients-extension-complete/specs/extension-vault/spec.md#requirement-detail-sections-for-every-kind-of-item + * @spec openspec/changes/clients-extension-complete/specs/extension-vault/spec.md#requirement-a-passkeys-private-key-stays-in-the-worker */ import { @@ -12,7 +13,6 @@ import { cardLast4, parsePayload, } from '../../../src/cardIdentity/cardIdentity.js' -import { parsePasskey } from '../../../src/passkey/passkey.js' import { generateTotp, parseOtpauth, @@ -184,6 +184,10 @@ export function renderDetail({ $, doc }, item, folders) { $(id).dataset.blocked = blocked ? 'true' : 'false' $(id).disabled = blocked || $('vault-offline').hidden === false } + // A clone would copy a passkey's key and a Send would carry it. + const isPasskey = formKind(item.typeName) === 'passkey' + $('detail-clone').hidden = isPasskey + $('detail-send').hidden = isPasskey if (blocked) { $('detail-blocked-reason').textContent = item.blockedReason $('detail-migration').textContent = item.migrationError || '' @@ -238,7 +242,7 @@ export function renderDetail({ $, doc }, item, folders) { sections.appendChild(el) } if (kind === 'passkey') { - const credential = parsePasskey(item.secret) + const credential = item.passkey const el = section(doc, 'Passkey') if (!credential) { const p = doc.createElement('p') diff --git a/browser-extension/src/popup/vault-view.js b/browser-extension/src/popup/vault-view.js index a6a4012c6..e0618009c 100644 --- a/browser-extension/src/popup/vault-view.js +++ b/browser-extension/src/popup/vault-view.js @@ -5,6 +5,7 @@ * * @spec openspec/changes/clients-extension-generator-vault-send/specs/extension-vault/spec.md#requirement-browse-and-search-the-vault * @spec openspec/changes/clients-extension-complete/specs/extension-vault/spec.md#requirement-edit-every-kind-of-item + * @spec openspec/changes/clients-extension-complete/specs/extension-vault/spec.md#requirement-a-passkeys-private-key-stays-in-the-worker */ import { @@ -268,7 +269,6 @@ export function initVault({ kind === 'login' || kind === 'generic' || kind === 'totp' - || kind === 'passkey' ) $('edit-generate').hidden = kind !== 'login' && kind !== 'generic' $('edit-secret-label').firstChild.textContent = @@ -314,7 +314,10 @@ export function initVault({ $('edit-type-label').hidden = !creating || clone fillSelect( $('edit-type'), - types.map((t) => ({ value: t.id, label: t.name })), + // Passkeys are created by the website that uses them. + types + .filter((t) => t.name !== 'passkey') + .map((t) => ({ value: t.id, label: t.name })), doc, ) $('edit-type').value = typeId diff --git a/openspec/changes/clients-extension-complete/specs/extension-vault/spec.md b/openspec/changes/clients-extension-complete/specs/extension-vault/spec.md index b3571c9cd..5a5dab43a 100644 --- a/openspec/changes/clients-extension-complete/specs/extension-vault/spec.md +++ b/openspec/changes/clients-extension-complete/specs/extension-vault/spec.md @@ -59,3 +59,18 @@ The Vault tab MUST offer a folder manager that shows the folders as a tree, sort - **GIVEN** Work holds items and the subfolder Clients - **WHEN** the user chooses to move Clients' items and confirms - **THEN** the delete request carries a plan for Work's items and for Clients + +### Requirement: A passkey's private key stays in the worker +The worker MUST NOT send a passkey's private key to the popup. The popup receives the site and account of a passkey only, and its edit form MUST NOT offer the key. The extension MUST NOT create a passkey item, change a passkey's key, clone a passkey or offer it for Send: a passkey is made and updated by the website that uses it. Name, address, folder and notes stay editable. + +#### Scenario: Opening a passkey +@e2e exclude Browser-extension worker and popup. Covered by tests/extension/popupItemDetail.spec.js ("keeps the private key in the worker: the popup gets site and account only") and ("shows a passkey without clone or Send, and its edit form has no key field"). +- **GIVEN** a passkey item in the vault +- **WHEN** the user opens it and then edits it +- **THEN** the popup shows its site and account, no clone or Send, no key field, and the private key appears nowhere in the popup + +#### Scenario: Creating or re-keying a passkey +@e2e exclude Browser-extension worker. Covered by tests/extension/popupItemDetail.spec.js ("refuses to create a passkey or change its key, and offers no passkey type for a new item"). +- **GIVEN** an unlocked vault +- **WHEN** a save would create a passkey or change a passkey's key +- **THEN** the worker refuses it, and the new-item form offers no passkey type diff --git a/tests/extension/popupItemDetail.spec.js b/tests/extension/popupItemDetail.spec.js index de07fc168..98c644bc6 100644 --- a/tests/extension/popupItemDetail.spec.js +++ b/tests/extension/popupItemDetail.spec.js @@ -136,6 +136,7 @@ beforeEach(async () => { { id: 't2', name: 'totp' }, { id: 't3', name: 'card' }, { id: 't4', name: 'note' }, + { id: 't5', name: 'passkey' }, ], folders: [ { id: 'f1', name: 'Work', parentId: null }, @@ -293,3 +294,95 @@ describe('changing items', () => { expect(JSON.stringify(post.body)).not.toContain('Lobby') }) }) + +describe('passkeys', () => { + const PRIVATE_KEY = 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPRIVATEKEY' + + /** Add a passkey row to the fake server and sync it into the snapshot. */ + async function addPasskey() { + const publicKey = await importPublicKey(RSA4096_PUBLIC_KEY_SPKI_PEM) + rows.push({ + id: 'p1', + name: 'GitHub passkey', + url: 'github.com', + typeId: 't5', + folderId: null, + login: await rsaEncrypt('', publicKey), + key: await rsaEncrypt( + JSON.stringify({ + credentialId: 'cred-1', + rpId: 'github.com', + rpName: 'GitHub', + userName: 'ann', + userHandle: 'aGFuZGxl', + privateKey: PRIVATE_KEY, + algorithm: -7, + counter: 3, + createdAt: '2026-09-01T10:00:00.000Z', + }), + publicKey, + ), + }) + await router.handleMessage({ type: 'vault-sync-now', payload: {} }, POPUP) + } + + it('keeps the private key in the worker: the popup gets site and account only', async () => { + await addPasskey() + const item = await router.handleMessage( + { type: 'vault-item', payload: { id: 'p1' } }, + POPUP, + ) + expect(item.passkey).toMatchObject({ rpId: 'github.com', userName: 'ann' }) + expect(item.secret).toBe('') + expect(JSON.stringify(item)).not.toContain(PRIVATE_KEY) + }) + + it('shows a passkey without clone or Send, and its edit form has no key field', async () => { + await addPasskey() + await openPopup() + await openByName('GitHub passkey') + expect($('detail-sections').textContent).toContain('GitHub (github.com)') + expect($('detail-clone').hidden).toBe(true) + expect($('detail-send').hidden).toBe(true) + $('detail-edit').click() + await vi.waitFor(() => expect($('vault-edit').hidden).toBe(false)) + expect($('edit-secret-block').hidden).toBe(true) + expect(document.body.innerHTML).not.toContain(PRIVATE_KEY) + expect($('edit-secret').value).toBe('') + }) + + it('refuses to create a passkey or change its key, and offers no passkey type for a new item', async () => { + await addPasskey() + const create = await router.handleMessage( + { + type: 'vault-save', + payload: { + typeId: 't5', + typeName: 'passkey', + changes: { name: 'X' }, + }, + }, + POPUP, + ) + expect(create.error).toMatch(/created by the website/) + const rekey = await router.handleMessage( + { + type: 'vault-save', + payload: { + id: 'p1', + typeId: 't5', + typeName: 'passkey', + changes: { key: '{}' }, + }, + }, + POPUP, + ) + expect(rekey.error).toMatch(/key cannot be edited/) + await openPopup() + $('vault-new').click() + await vi.waitFor(() => expect($('vault-edit').hidden).toBe(false)) + expect([...$('edit-type').options].map((o) => o.textContent)).not.toContain( + 'passkey', + ) + }) +}) From 792bc929befe4344729c788162f416816aa7713f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 3 Oct 2026 23:25:21 +0200 Subject: [PATCH 140/245] feat(admin-api): a versioned admin API under /api/v1/admin (stacked on #962) (#966) * feat(admin-api): a versioned admin API under /api/v1/admin Add the v1 admin API for scripts, each endpoint guarded by one admin area in Nextcloud's middleware: an index (any area), policies (Policies), suite listing and offboarding (People), applications with registration, approval, rejection, deletion and lease policy (Applications), and audit events, compliance reports and SIEM sinks (Audit). Writes carry a user rate limit. Force revocation and reinstatement stay out: both need a fresh password confirmation. docs/api/admin-v1.openapi.json describes every route (OpenAPI 3.1, linted by a new workflow); AdminApiContractTest keeps it, the route table and the index in step, and checks each route's area guard. A Newman collection seeds an Audit-only service account and checks its refusals. The member overview waits on #895. Refs #772 * feat(admin-api): an application's public certificate in GET /api/v1/admin/applications/{id} The Terraform provider registers an application from a CSR and needs the certificate Keepiq signed. Additive to v1. Refs #772 * feat(admin-api): document GET /api/v1/admin/members and list it in the index The member overview from #965 already sits at /api/v1/admin/members with a People area guard. Add it to the OpenAPI document (query parameters, row shape), the index path list and the docs page, and a contract test that ties the documented parameters, row fields and statuses to the controller and service. Task 1.2 is done. Refs #772 --- .github/workflows/admin-api.yml | 30 + appinfo/routes.php | 27 + docs/api/admin-v1.openapi.json | 1164 +++++++++++++ docs/tutorials/admin/02-admin-api.md | 73 + lib/Controller/AdminApplicationController.php | 316 ++++ lib/Controller/AdminAuditController.php | 267 +++ lib/Controller/AdminIndexController.php | 132 ++ lib/Controller/AdminPeopleController.php | 154 ++ openspec/changes/admin-public-api/design.md | 23 +- .../admin-public-api/specs/admin-api/spec.md | 4 +- openspec/changes/admin-public-api/tasks.md | 20 +- .../AppInfo/RoutesWithoutOpenRegisterTest.php | 5 +- tests/Unit/Contract/AdminApiContractTest.php | 236 +++ .../AdminApplicationControllerTest.php | 244 +++ .../Controller/AdminAuditControllerTest.php | 175 ++ .../Controller/AdminIndexControllerTest.php | 120 ++ .../Controller/AdminPeopleControllerTest.php | 133 ++ .../admin-api.postman_collection.json | 1483 +++++++++++++++++ tests/integration/run-newman.sh | 13 + 19 files changed, 4596 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/admin-api.yml create mode 100644 docs/api/admin-v1.openapi.json create mode 100644 docs/tutorials/admin/02-admin-api.md create mode 100644 lib/Controller/AdminApplicationController.php create mode 100644 lib/Controller/AdminAuditController.php create mode 100644 lib/Controller/AdminIndexController.php create mode 100644 lib/Controller/AdminPeopleController.php create mode 100644 tests/Unit/Contract/AdminApiContractTest.php create mode 100644 tests/Unit/Controller/AdminApplicationControllerTest.php create mode 100644 tests/Unit/Controller/AdminAuditControllerTest.php create mode 100644 tests/Unit/Controller/AdminIndexControllerTest.php create mode 100644 tests/Unit/Controller/AdminPeopleControllerTest.php create mode 100644 tests/integration/admin-api.postman_collection.json diff --git a/.github/workflows/admin-api.yml b/.github/workflows/admin-api.yml new file mode 100644 index 000000000..6a29d1655 --- /dev/null +++ b/.github/workflows/admin-api.yml @@ -0,0 +1,30 @@ +name: Admin API document + +# Lints docs/api/admin-v1.openapi.json as OpenAPI 3.1 (admin-public-api §2.1). +# That the document and appinfo/routes.php agree is checked by PHPUnit +# (tests/Unit/Contract/AdminApiContractTest.php) in the code quality workflow. + +on: + push: + branches: [main, development] + paths: ["docs/api/**", ".github/workflows/admin-api.yml"] + pull_request: + branches: [main, development, beta] + paths: ["docs/api/**", ".github/workflows/admin-api.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npx --yes @redocly/cli@1 lint docs/api/admin-v1.openapi.json diff --git a/appinfo/routes.php b/appinfo/routes.php index bda83c7b8..d2cf18d44 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -54,6 +54,33 @@ ['name' => 'settings#getPolicy', 'url' => '/api/settings/policy', 'verb' => 'GET'], ['name' => 'settings#updateUserSettings', 'url' => '/api/settings/user', 'verb' => 'PUT'], + // Admin API v1 (admin-public-api): a documented, versioned surface for + // scripts, each route guarded by one admin area (admin-scoped-roles). + // docs/api/admin-v1.openapi.json describes exactly these routes + // (AdminApiContractTest). The policies pair reuses the area settings + // methods; `postfix` keeps their route names distinct. + ['name' => 'adminIndex#index', 'url' => '/api/v1/admin', 'verb' => 'GET'], + ['name' => 'adminAreaSettings#getPolicySettings', 'url' => '/api/v1/admin/policies', 'verb' => 'GET', 'postfix' => 'AdminApi'], + ['name' => 'adminAreaSettings#updatePolicySettings', 'url' => '/api/v1/admin/policies', 'verb' => 'PUT', 'postfix' => 'AdminApi'], + ['name' => 'adminPeople#suites', 'url' => '/api/v1/admin/suites', 'verb' => 'GET'], + ['name' => 'adminPeople#offboard', 'url' => '/api/v1/admin/offboarding', 'verb' => 'POST'], + ['name' => 'adminApplication#index', 'url' => '/api/v1/admin/applications', 'verb' => 'GET'], + ['name' => 'adminApplication#create', 'url' => '/api/v1/admin/applications', 'verb' => 'POST'], + ['name' => 'adminApplication#approve', 'url' => '/api/v1/admin/applications/{id}/approve', 'verb' => 'POST'], + ['name' => 'adminApplication#reject', 'url' => '/api/v1/admin/applications/{id}/reject', 'verb' => 'POST'], + ['name' => 'adminApplication#getLeasePolicy', 'url' => '/api/v1/admin/applications/{id}/lease-policy', 'verb' => 'GET'], + ['name' => 'adminApplication#setLeasePolicy', 'url' => '/api/v1/admin/applications/{id}/lease-policy', 'verb' => 'PUT'], + ['name' => 'adminApplication#show', 'url' => '/api/v1/admin/applications/{id}', 'verb' => 'GET'], + ['name' => 'adminApplication#destroy', 'url' => '/api/v1/admin/applications/{id}', 'verb' => 'DELETE'], + ['name' => 'adminAudit#events', 'url' => '/api/v1/admin/audit', 'verb' => 'GET'], + ['name' => 'adminAudit#reports', 'url' => '/api/v1/admin/compliance/reports', 'verb' => 'GET'], + ['name' => 'adminAudit#generateReport', 'url' => '/api/v1/admin/compliance/reports', 'verb' => 'POST'], + ['name' => 'adminAudit#showReport', 'url' => '/api/v1/admin/compliance/reports/{id}', 'verb' => 'GET'], + ['name' => 'adminAudit#sinks', 'url' => '/api/v1/admin/siem/sinks', 'verb' => 'GET'], + ['name' => 'adminAudit#createSink', 'url' => '/api/v1/admin/siem/sinks', 'verb' => 'POST'], + ['name' => 'adminAudit#updateSink', 'url' => '/api/v1/admin/siem/sinks/{id}', 'verb' => 'PUT'], + ['name' => 'adminAudit#destroySink', 'url' => '/api/v1/admin/siem/sinks/{id}', 'verb' => 'DELETE'], + // EncryptionSuite CRUD. ['name' => 'encryptionSuite#index', 'url' => '/api/v1/suites', 'verb' => 'GET'], ['name' => 'encryptionSuite#show', 'url' => '/api/v1/suites/{id}', 'verb' => 'GET'], diff --git a/docs/api/admin-v1.openapi.json b/docs/api/admin-v1.openapi.json new file mode 100644 index 000000000..0c1202dde --- /dev/null +++ b/docs/api/admin-v1.openapi.json @@ -0,0 +1,1164 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Keepiq admin API", + "version": "1", + "description": "The versioned admin API of Keepiq. Authenticate as a Nextcloud user: a browser session, or an app password over HTTP Basic with the header OCS-APIRequest: true. Each endpoint needs one Keepiq admin area, delegated on Nextcloud's administration privileges page. Version 1 only grows; a breaking change ships as version 2 next to it. Suite force revocation and reinstatement are not offered, because both need a fresh password confirmation.", + "license": { + "name": "EUPL-1.2", + "identifier": "EUPL-1.2" + } + }, + "servers": [ + { + "url": "{base}/index.php/apps/keepiq", + "variables": { + "base": { + "default": "https://cloud.example.com" + } + } + } + ], + "security": [ + { + "basicAuth": [] + } + ], + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic", + "description": "A Nextcloud app password. Send OCS-APIRequest: true with every request." + } + }, + "parameters": { + "OcsApiRequest": { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "const": "true" + } + }, + "Id": { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + }, + "responses": { + "Error": { + "description": "Refused or invalid", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "paths": { + "/api/v1/admin": { + "get": { + "operationId": "indexIndex", + "summary": "The admin API index", + "description": "Returns the API version, the served versions, the areas you hold and every v1 path. Needs at least one Keepiq admin area. Admin area: any Keepiq admin area.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "any" + } + }, + "/api/v1/admin/policies": { + "get": { + "operationId": "getPolicySettings", + "summary": "Read the policies", + "description": "The settings of the Policies area: master password, organisation password, vault policies, rotation and expiry, session timeout, version and trash retention, extension idle limit. Admin area: Policies.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "policies" + }, + "put": { + "operationId": "updatePolicySettings", + "summary": "Update the policies", + "description": "Writes only Policies keys. A key of another area answers 400 and nothing is written. Admin area: Policies.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "policies", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "/api/v1/admin/members": { + "get": { + "operationId": "memberOverviewIndex", + "summary": "List members and their vault status", + "description": "One page of Nextcloud users with their vault status, secret count, team folder memberships and whether they have an emergency contact. Metadata only. Admin area: People and offboarding.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "", + "none", + "active", + "revoked", + "compromised" + ], + "default": "" + }, + "description": "Vault status filter; empty for every status." + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "default": "" + }, + "description": "Search on user id or display name." + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "results", + "limit", + "offset", + "hasMore" + ], + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "vaultStatus": { + "type": "string", + "enum": [ + "none", + "active", + "revoked", + "compromised" + ] + }, + "activeSuiteId": { + "type": [ + "string", + "null" + ] + }, + "suiteCreatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "secretCount": { + "type": "integer" + }, + "teamFolderMemberships": { + "type": "integer" + }, + "hasEmergencyContact": { + "type": "boolean" + } + } + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "hasMore": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "people" + } + }, + "/api/v1/admin/suites": { + "get": { + "operationId": "peopleSuites", + "summary": "List active encryption suites", + "description": "One page of active suites, metadata only. Never a private key or a certificate. Admin area: People and offboarding.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 100 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "people" + } + }, + "/api/v1/admin/offboarding": { + "post": { + "operationId": "peopleOffboard", + "summary": "Offboard a leaver", + "description": "Revokes the leaver's team-folder access, removes their direct memberships and transfers the team secrets they own to the successor. Admin area: People and offboarding.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "people", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "leavingUserId", + "successorUserId" + ], + "properties": { + "leavingUserId": { + "type": "string" + }, + "successorUserId": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/admin/applications": { + "get": { + "operationId": "applicationIndex", + "summary": "List applications", + "description": "Every application. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + }, + "post": { + "operationId": "applicationCreate", + "summary": "Register an application", + "description": "Registers an application. Your registration is active at once. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "internal", + "external" + ] + }, + "csr": { + "type": "string", + "description": "PKCS#10 CSR in PEM" + } + } + } + } + } + } + } + }, + "/api/v1/admin/applications/{id}": { + "get": { + "operationId": "applicationShow", + "summary": "Read an application", + "description": "The application, with its public certificate (PEM) in `certificate` when it is active. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + }, + "delete": { + "operationId": "applicationDestroy", + "summary": "Delete an application", + "description": "Deletes the application and its vault. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + } + }, + "/api/v1/admin/applications/{id}/approve": { + "post": { + "operationId": "applicationApprove", + "summary": "Approve a pending application", + "description": "You are recorded as approver. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + } + }, + "/api/v1/admin/applications/{id}/reject": { + "post": { + "operationId": "applicationReject", + "summary": "Reject a pending application", + "description": "Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + } + }, + "/api/v1/admin/applications/{id}/lease-policy": { + "get": { + "operationId": "applicationGetLeasePolicy", + "summary": "Read the lease policy", + "description": "The application's override and the effective values. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + }, + "put": { + "operationId": "applicationSetLeasePolicy", + "summary": "Set the lease policy", + "description": "A null value inherits the instance setting. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "defaultTtl": { + "type": [ + "integer", + "null" + ], + "minimum": 60 + }, + "maxTtl": { + "type": [ + "integer", + "null" + ], + "minimum": 60 + }, + "renewable": { + "type": [ + "boolean", + "null" + ] + } + } + } + } + } + } + } + }, + "/api/v1/admin/audit": { + "get": { + "operationId": "auditEvents", + "summary": "Read audit events", + "description": "Filtered and paged like the admin screen. Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "name": "eventType", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "actor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "objectType", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + } + }, + "/api/v1/admin/compliance/reports": { + "get": { + "operationId": "auditReports", + "summary": "List compliance reports", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + }, + "post": { + "operationId": "auditGenerateReport", + "summary": "Generate a compliance report", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + } + }, + "/api/v1/admin/compliance/reports/{id}": { + "get": { + "operationId": "auditShowReport", + "summary": "Read a compliance report", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + } + }, + "/api/v1/admin/siem/sinks": { + "get": { + "operationId": "auditSinks", + "summary": "List SIEM sinks", + "description": "A sink's HMAC secret and connector credential never appear. Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + }, + "post": { + "operationId": "auditCreateSink", + "summary": "Create a SIEM sink", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "/api/v1/admin/siem/sinks/{id}": { + "put": { + "operationId": "auditUpdateSink", + "summary": "Update a SIEM sink", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "delete": { + "operationId": "auditDestroySink", + "summary": "Delete a SIEM sink", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + } + } + } +} diff --git a/docs/tutorials/admin/02-admin-api.md b/docs/tutorials/admin/02-admin-api.md new file mode 100644 index 000000000..3496813e8 --- /dev/null +++ b/docs/tutorials/admin/02-admin-api.md @@ -0,0 +1,73 @@ +--- +sidebar_position: 2 +title: Script Keepiq administration with the admin API +description: Use the versioned admin API with a Nextcloud app password, scoped to the admin areas a script needs. +--- + +# Script Keepiq administration with the admin API + +Keepiq has a documented, versioned admin API under `/api/v1/admin`. Use it to approve applications, offboard a leaver or export audit events from a script. The full description is the OpenAPI 3.1 document [`docs/api/admin-v1.openapi.json`](https://github.com/ConductionNL/keepiq/blob/development/docs/api/admin-v1.openapi.json). + +## Goal + +By the end of this guide a script calls the admin API as a service account. That account can do exactly the admin jobs you gave it, and nothing else. + +## Prerequisites + +- A Nextcloud admin account on an instance where Keepiq is installed. +- A Nextcloud user for the script, for example `svc-keepiq-audit`. + +## 1. Give the service account only the areas it needs + +Keepiq administration has five areas: General, Policies, Applications and machine access, People and offboarding, and Audit and compliance. + +1. Create a group, for example `keepiq-audit-scripts`, and add the service account to it. +2. Open **Administration settings > Administration privileges**. +3. Find **Keepiq - Audit and compliance** and add the group. + +The account now holds the Audit area. Every other admin endpoint refuses it. + +## 2. Create an app password + +Log in as the service account and open **Personal settings > Security**. Create an app password named after the integration. Store it in a secret store, ideally Keepiq's own machine API, not in the script. + +To cut the integration off, revoke that app password. + +## 3. Call the API + +Send the app password over HTTP Basic, with the header `OCS-APIRequest: true`: + +```bash +curl -u svc-keepiq-audit:APP_PASSWORD \ + -H 'OCS-APIRequest: true' -H 'Accept: application/json' \ + https://cloud.example.com/index.php/apps/keepiq/api/v1/admin +``` + +The index returns `apiVersion`, the versions the server offers, the areas you hold and every path. Then read audit events: + +```bash +curl -u svc-keepiq-audit:APP_PASSWORD -H 'OCS-APIRequest: true' \ + 'https://cloud.example.com/index.php/apps/keepiq/api/v1/admin/audit?eventType=share.granted&limit=50' +``` + +## What v1 offers + +| Area | Endpoints | +|---|---| +| Any area | `GET /api/v1/admin` | +| Policies | `GET`, `PUT /policies` | +| People and offboarding | `GET /members`, `GET /suites`, `POST /offboarding` | +| Applications and machine access | `GET`, `POST /applications`; `GET`, `DELETE /applications/{id}`; `POST /applications/{id}/approve` and `/reject`; `GET`, `PUT /applications/{id}/lease-policy` | +| Audit and compliance | `GET /audit`; `GET`, `POST /compliance/reports`; `GET /compliance/reports/{id}`; `GET`, `POST /siem/sinks`; `PUT`, `DELETE /siem/sinks/{id}` | + +Every response is metadata: ids, statuses, counts, dates and settings. No response carries a private key, a secret value, ciphertext or a SIEM credential. + +Two jobs stay in the web interface. Force revocation and reinstatement of an encryption suite ask you to confirm your own password at that moment, and a stored app password cannot do that. + +## Versioning + +Version 1 only grows. New endpoints and new fields can appear in it. A removed or renamed field, or a changed status code, ships as `/api/v2/admin` next to v1, and v1 stays for at least one more minor release. + +## Next step + +Give the Terraform provider an app password with the Applications area, and manage your applications as code. diff --git a/lib/Controller/AdminApplicationController.php b/lib/Controller/AdminApplicationController.php new file mode 100644 index 000000000..6398820b6 --- /dev/null +++ b/lib/Controller/AdminApplicationController.php @@ -0,0 +1,316 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application as KeepiqApp; +use OCA\Keepiq\Db\Application; +use OCA\Keepiq\Db\ApplicationMapper; +use OCA\Keepiq\Service\ApplicationService; +use OCA\Keepiq\Service\LeaseService; +use OCA\Keepiq\Settings\ApplicationAdminSettings; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * `/api/v1/admin/applications`. The area guard runs in Nextcloud's + * middleware, so every service call below runs as an administrator. + */ +class AdminApplicationController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The request + * @param ApplicationService $applications The application service the admin screen uses + * @param LeaseService $leases The lease policy service + * @param ApplicationMapper $applicationMapper Existence check for the lease policy + * @param IUserSession $userSession The acting administrator + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private ApplicationService $applications, + private LeaseService $leases, + private ApplicationMapper $applicationMapper, + private IUserSession $userSession, + ) { + parent::__construct(appName: KeepiqApp::APP_ID, request: $request); + }//end __construct() + + /** + * Every application. + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + public function index(): JSONResponse { + return new JSONResponse( + data: array_map( + static fn (Application $application): array => $application->jsonSerialize(), + $this->applications->listForUser($this->actor(), true) + ) + ); + }//end index() + + /** + * One application, with its public certificate when it is active. + * + * @param string $id The application id + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + public function show(string $id): JSONResponse { + try { + $application = $this->applications->get($id, $this->actor(), true)->jsonSerialize(); + } catch (InvalidArgumentException $exception) { + return $this->notFound(message: $exception->getMessage()); + } + + // The public certificate of an active application, so a script that + // registered it from a CSR can read what Keepiq signed. + $application['certificate'] = null; + if ($application['status'] === 'active') { + $application['certificate'] = $this->applications->getCertificate(applicationId: $id); + } + + return new JSONResponse(data: $application); + }//end show() + + /** + * Register an application. An administrator's registration is active at + * once, so it needs no separate approval (application-mgmt). + * + * @param string $name The application name + * @param string|null $description An optional description + * @param string $type internal or external + * @param string|null $csr An optional PKCS#10 CSR in PEM + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function create( + string $name = '', + ?string $description = null, + string $type = Application::TYPE_EXTERNAL, + ?string $csr = null, + ): JSONResponse { + if (trim($name) === '') { + return new JSONResponse(data: ['message' => 'name is required'], statusCode: Http::STATUS_BAD_REQUEST); + } + + try { + $entity = $this->applications->register( + name: $name, + description: $description, + type: $type, + csr: $csr, + userId: $this->actor(), + isAdmin: true + ); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $entity->jsonSerialize(), statusCode: Http::STATUS_CREATED); + }//end create() + + /** + * Approve a pending application, recording the caller as approver. + * + * @param string $id The application id + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 60, period: 60)] + public function approve(string $id): JSONResponse { + try { + return new JSONResponse(data: $this->applications->approve(applicationId: $id, adminUserId: $this->actor(), isAdmin: true)->jsonSerialize()); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + }//end approve() + + /** + * Reject a pending application. + * + * @param string $id The application id + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 60, period: 60)] + public function reject(string $id): JSONResponse { + try { + $this->applications->reject(applicationId: $id, adminUserId: $this->actor(), isAdmin: true); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: ['status' => 'rejected', 'id' => $id]); + }//end reject() + + /** + * Delete an application and its vault. + * + * @param string $id The application id + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function destroy(string $id): JSONResponse { + try { + $this->applications->delete(applicationId: $id, isAdmin: true); + } catch (InvalidArgumentException $exception) { + return $this->notFound(message: $exception->getMessage()); + } + + return new JSONResponse(data: ['status' => 'deleted', 'id' => $id]); + }//end destroy() + + /** + * The application's lease policy: its override and the effective values. + * + * @param string $id The application id + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + public function getLeasePolicy(string $id): JSONResponse { + if ($this->exists(id: $id) === false) { + return $this->notFound(message: 'Application not found'); + } + + return new JSONResponse(data: $this->leases->policyView(applicationId: $id)); + }//end getLeasePolicy() + + /** + * Set the application's lease policy override; null inherits. + * + * @param string $id The application id + * @param int|null $defaultTtl Default lease TTL in seconds, at least 60 + * @param int|null $maxTtl Maximum lease TTL in seconds, at least 60 + * @param bool|null $renewable Whether a lease may be renewed + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 60, period: 60)] + public function setLeasePolicy(string $id, ?int $defaultTtl = null, ?int $maxTtl = null, ?bool $renewable = null): JSONResponse { + if ($this->exists(id: $id) === false) { + return $this->notFound(message: 'Application not found'); + } + + try { + $this->leases->setPolicyOverride(applicationId: $id, defaultTtl: $defaultTtl, maxTtl: $maxTtl, renewable: $renewable); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $this->leases->policyView(applicationId: $id)); + }//end setLeasePolicy() + + /** + * The acting administrator's uid. + * + * @return string + */ + private function actor(): string { + return (string)$this->userSession->getUser()?->getUID(); + }//end actor() + + /** + * Whether an application exists. + * + * @param string $id The application id + * + * @return bool + */ + private function exists(string $id): bool { + try { + $this->applicationMapper->findById($id); + } catch (DoesNotExistException) { + return false; + } + + return true; + }//end exists() + + /** + * A 404 with a message. + * + * @param string $message The message + * + * @return JSONResponse + */ + private function notFound(string $message): JSONResponse { + return new JSONResponse(data: ['message' => $message], statusCode: Http::STATUS_NOT_FOUND); + }//end notFound() +}//end class diff --git a/lib/Controller/AdminAuditController.php b/lib/Controller/AdminAuditController.php new file mode 100644 index 000000000..151ae6ea2 --- /dev/null +++ b/lib/Controller/AdminAuditController.php @@ -0,0 +1,267 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\AuditService; +use OCA\Keepiq\Service\ComplianceReportService; +use OCA\Keepiq\Service\Siem\SiemSinkRequest; +use OCA\Keepiq\Service\SiemService; +use OCA\Keepiq\Settings\AuditAdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * `/api/v1/admin/audit`, `/compliance/reports` and `/siem/sinks`. + */ +class AdminAuditController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The request + * @param AuditService $audit The audit query the admin screen uses + * @param ComplianceReportService $reports The compliance reports + * @param SiemService $siem The SIEM sinks + * @param IUserSession $userSession The acting administrator + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private AuditService $audit, + private ComplianceReportService $reports, + private SiemService $siem, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Audit events, filtered and paged like the admin screen. + * + * @param string|null $eventType Event type filter + * @param string|null $actor Actor filter + * @param string|null $objectType Object type filter + * @param string|null $objectId Object id filter + * @param string|null $from ISO 8601 lower bound + * @param string|null $to ISO 8601 upper bound + * @param int $page 1-based page + * @param int $limit Page size + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + public function events( + ?string $eventType = null, + ?string $actor = null, + ?string $objectType = null, + ?string $objectId = null, + ?string $from = null, + ?string $to = null, + int $page = 1, + int $limit = 50, + ): JSONResponse { + $filters = [ + 'eventType' => $eventType, + 'actor' => $actor, + 'objectType' => $objectType, + 'objectId' => $objectId, + 'from' => $from, + 'to' => $to, + ]; + + return new JSONResponse(data: $this->audit->adminQuery($filters, $page, $limit)); + }//end events() + + /** + * The compliance reports, newest first. + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + public function reports(): JSONResponse { + return new JSONResponse( + data: array_map( + static fn (object $report): array => [ + 'id' => $report->getId(), + 'generatedBy' => $report->getGeneratedBy(), + 'generatedAt' => $report->getGeneratedAt()?->format('c'), + 'appVersion' => $report->getAppVersion(), + ], + $this->reports->listReports() + ) + ); + }//end reports() + + /** + * Generate a compliance report now. + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + #[UserRateLimit(limit: 10, period: 60)] + public function generateReport(): JSONResponse { + return new JSONResponse( + data: $this->reports->generate(adminUid: $this->actor())->jsonSerialize(), + statusCode: Http::STATUS_CREATED + ); + }//end generateReport() + + /** + * One compliance report. + * + * @param string $id The report id + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + public function showReport(string $id): JSONResponse { + try { + return new JSONResponse(data: $this->reports->getReport(id: $id)->jsonSerialize()); + } catch (DoesNotExistException) { + return new JSONResponse(data: ['message' => 'Report not found'], statusCode: Http::STATUS_NOT_FOUND); + } + }//end showReport() + + /** + * The SIEM sinks. A sink's HMAC secret and connector credential never + * appear in its serialized form. + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + public function sinks(): JSONResponse { + return new JSONResponse( + data: array_map(static fn (object $sink): array => $sink->jsonSerialize(), $this->siem->listSinks()) + ); + }//end sinks() + + /** + * Create a SIEM sink; the body is the admin screen's. + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function createSink(): JSONResponse { + try { + $sink = $this->siem->createSink(adminUid: $this->actor(), params: (new SiemSinkRequest(request: $this->request))->forCreate()); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $sink->jsonSerialize(), statusCode: Http::STATUS_CREATED); + }//end createSink() + + /** + * Update a SIEM sink. + * + * @param string $id The sink id + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function updateSink(string $id): JSONResponse { + try { + $sink = $this->siem->updateSink(adminUid: $this->actor(), sinkId: $id, params: (new SiemSinkRequest(request: $this->request))->forUpdate()); + } catch (DoesNotExistException) { + return new JSONResponse(data: ['message' => 'Sink not found'], statusCode: Http::STATUS_NOT_FOUND); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $sink->jsonSerialize()); + }//end updateSink() + + /** + * Delete a SIEM sink. + * + * @param string $id The sink id + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function destroySink(string $id): JSONResponse { + try { + $this->siem->deleteSink(adminUid: $this->actor(), sinkId: $id); + } catch (DoesNotExistException) { + return new JSONResponse(data: ['message' => 'Sink not found'], statusCode: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: ['deleted' => true]); + }//end destroySink() + + /** + * The acting administrator's uid. + * + * @return string + */ + private function actor(): string { + return (string)$this->userSession->getUser()?->getUID(); + }//end actor() +}//end class diff --git a/lib/Controller/AdminIndexController.php b/lib/Controller/AdminIndexController.php new file mode 100644 index 000000000..f28baeb89 --- /dev/null +++ b/lib/Controller/AdminIndexController.php @@ -0,0 +1,132 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\AdminAreaAuthorizer; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * `GET /api/v1/admin`: the version and every path. + */ +class AdminIndexController extends Controller { + /** + * The admin API version this index describes. + * + * @var int + */ + public const API_VERSION = 1; + + /** + * Every v1 path, with its method and admin area. Force revocation and + * suite reinstatement are left out on purpose: both need a fresh password + * confirmation that a stored app password cannot give (design D4). + * + * @var array + */ + public const PATHS = [ + ['method' => 'GET', 'path' => '/api/v1/admin', 'area' => 'any'], + ['method' => 'GET', 'path' => '/api/v1/admin/policies', 'area' => 'policies'], + ['method' => 'PUT', 'path' => '/api/v1/admin/policies', 'area' => 'policies'], + ['method' => 'GET', 'path' => '/api/v1/admin/members', 'area' => 'people'], + ['method' => 'GET', 'path' => '/api/v1/admin/suites', 'area' => 'people'], + ['method' => 'POST', 'path' => '/api/v1/admin/offboarding', 'area' => 'people'], + ['method' => 'GET', 'path' => '/api/v1/admin/applications', 'area' => 'applications'], + ['method' => 'POST', 'path' => '/api/v1/admin/applications', 'area' => 'applications'], + ['method' => 'GET', 'path' => '/api/v1/admin/applications/{id}', 'area' => 'applications'], + ['method' => 'DELETE', 'path' => '/api/v1/admin/applications/{id}', 'area' => 'applications'], + ['method' => 'POST', 'path' => '/api/v1/admin/applications/{id}/approve', 'area' => 'applications'], + ['method' => 'POST', 'path' => '/api/v1/admin/applications/{id}/reject', 'area' => 'applications'], + ['method' => 'GET', 'path' => '/api/v1/admin/applications/{id}/lease-policy', 'area' => 'applications'], + ['method' => 'PUT', 'path' => '/api/v1/admin/applications/{id}/lease-policy', 'area' => 'applications'], + ['method' => 'GET', 'path' => '/api/v1/admin/audit', 'area' => 'audit'], + ['method' => 'GET', 'path' => '/api/v1/admin/compliance/reports', 'area' => 'audit'], + ['method' => 'POST', 'path' => '/api/v1/admin/compliance/reports', 'area' => 'audit'], + ['method' => 'GET', 'path' => '/api/v1/admin/compliance/reports/{id}', 'area' => 'audit'], + ['method' => 'GET', 'path' => '/api/v1/admin/siem/sinks', 'area' => 'audit'], + ['method' => 'POST', 'path' => '/api/v1/admin/siem/sinks', 'area' => 'audit'], + ['method' => 'PUT', 'path' => '/api/v1/admin/siem/sinks/{id}', 'area' => 'audit'], + ['method' => 'DELETE', 'path' => '/api/v1/admin/siem/sinks/{id}', 'area' => 'audit'], + ]; + + /** + * Constructor. + * + * @param IRequest $request The request + * @param IUserSession $userSession The session user + * @param AdminAreaAuthorizer $areas The admin areas the caller holds + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private IUserSession $userSession, + private AdminAreaAuthorizer $areas, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * The admin API index, for any holder of at least one admin area. One + * attribute names one class, so "any area" is checked here instead. + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.1 + */ + #[NoAdminRequired] + public function index(): JSONResponse { + $userId = $this->userSession->getUser()?->getUID(); + $held = []; + if ($userId !== null) { + $held = $this->areas->areasOf(userId: $userId); + } + + if ($held === []) { + return new JSONResponse(data: ['message' => 'The admin API needs a Keepiq admin area'], statusCode: Http::STATUS_FORBIDDEN); + } + + return new JSONResponse( + data: [ + 'apiVersion' => self::API_VERSION, + 'versions' => ['v1'], + 'areas' => $held, + 'paths' => self::PATHS, + 'notOffered' => [ + 'suite force revocation and reinstatement: both need a fresh password confirmation, which an app password cannot give', + ], + ] + ); + }//end index() +}//end class diff --git a/lib/Controller/AdminPeopleController.php b/lib/Controller/AdminPeopleController.php new file mode 100644 index 000000000..15d831eba --- /dev/null +++ b/lib/Controller/AdminPeopleController.php @@ -0,0 +1,154 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Db\EncryptionSuite; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Service\TeamFolderService; +use OCA\Keepiq\Settings\PeopleAdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * `GET /api/v1/admin/suites` and `POST /api/v1/admin/offboarding`. + */ +class AdminPeopleController extends Controller { + /** + * The largest suite page a script may ask for. + * + * @var int + */ + private const MAX_LIMIT = 500; + + /** + * Constructor. + * + * @param IRequest $request The request + * @param EncryptionSuiteMapper $suites The suite rows + * @param TeamFolderService $teamFolders The offboarding entry point the admin screen uses + * @param IUserSession $userSession The acting administrator + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private EncryptionSuiteMapper $suites, + private TeamFolderService $teamFolders, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Active suites, one page, metadata only: never the private key blob or + * the certificate. + * + * @param int $limit Page size, 1 to 500 + * @param int $offset Rows to skip + * + * @AuthorizedAdminSetting(PeopleAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.2 + */ + #[AuthorizedAdminSetting(PeopleAdminSettings::class)] + public function suites(int $limit = 100, int $offset = 0): JSONResponse { + $limit = max(1, min(self::MAX_LIMIT, $limit)); + $offset = max(0, $offset); + + return new JSONResponse( + data: [ + 'limit' => $limit, + 'offset' => $offset, + 'results' => array_map( + static fn (EncryptionSuite $suite): array => self::suiteRow(suite: $suite), + $this->suites->findAllActiveWithLimit($limit, $offset) + ), + ] + ); + }//end suites() + + /** + * Offboard a leaver: the same service call as the admin screen, which + * also checks the People area itself. + * + * @param string $leavingUserId The user being offboarded + * @param string $successorUserId The user taking over owned team secrets + * + * @AuthorizedAdminSetting(PeopleAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/admin-public-api/tasks.md#1.2 + */ + #[AuthorizedAdminSetting(PeopleAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function offboard(string $leavingUserId = '', string $successorUserId = ''): JSONResponse { + try { + $summary = $this->teamFolders->offboard( + leavingUserId: $leavingUserId, + successorUserId: $successorUserId, + adminId: (string)$this->userSession->getUser()?->getUID() + ); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $summary); + }//end offboard() + + /** + * The metadata of one suite. + * + * @param EncryptionSuite $suite The suite + * + * @return array + */ + private static function suiteRow(EncryptionSuite $suite): array { + $row = $suite->jsonSerialize(); + + return [ + 'id' => $row['id'], + 'ownerType' => $row['ownerType'], + 'ownerId' => $row['ownerId'], + 'status' => $row['status'], + 'createdAt' => $row['createdAt'], + 'revokedAt' => $row['revokedAt'], + 'revokedBy' => $row['revokedBy'], + 'reinstatedAt' => $row['reinstatedAt'], + 'reinstatedBy' => $row['reinstatedBy'], + ]; + }//end suiteRow() +}//end class diff --git a/openspec/changes/admin-public-api/design.md b/openspec/changes/admin-public-api/design.md index 96192d602..a29564ab5 100644 --- a/openspec/changes/admin-public-api/design.md +++ b/openspec/changes/admin-public-api/design.md @@ -34,17 +34,20 @@ v1 endpoints: | Method and path | Area | Service | |---|---|---| -| `GET /api/v1/admin` | any area | index: `apiVersion`, paths | -| `GET /api/v1/admin/members` | People | member overview (change `admin-member-overview-and-offboarding`) | -| `POST /api/v1/admin/offboarding` | People | `TeamFolderOffboardingService::offboard()` | -| `GET /api/v1/admin/suites`, `POST /api/v1/admin/suites/{id}/reinstate` | People | `EncryptionSuiteService` | -| `GET`, `PUT /api/v1/admin/policies` | Policies | `AdminSettingsService` | -| `GET /api/v1/admin/applications`, `POST .../{id}/approve`, `POST .../{id}/reject`, `DELETE .../{id}` | Applications | `ApplicationService` | -| `GET /api/v1/admin/audit` | Audit | `AuditService` | +| `GET /api/v1/admin` | any area | index: `apiVersion`, versions, the caller's areas, paths | +| `GET /api/v1/admin/members` | People | `MemberOverviewController::index()` (change `admin-member-overview-and-offboarding`, #965): paged users with vault status, metadata only | +| `POST /api/v1/admin/offboarding` | People | `TeamFolderService::offboard()` | +| `GET /api/v1/admin/suites` | People | `EncryptionSuiteMapper::findAllActiveWithLimit()`, metadata only | +| `GET`, `PUT /api/v1/admin/policies` | Policies | the Policies area settings (`AdminAreaSettingsController`) | +| `GET`, `POST /api/v1/admin/applications`, `GET`, `DELETE .../{id}`, `POST .../{id}/approve`, `POST .../{id}/reject` | Applications | `ApplicationService` | +| `GET`, `PUT /api/v1/admin/applications/{id}/lease-policy` | Applications | `LeaseService` | +| `GET /api/v1/admin/audit` | Audit | `AuditService::adminQuery()` | | `GET`, `POST /api/v1/admin/compliance/reports`, `GET .../{id}` | Audit | `ComplianceReportService` | -| `GET`, `POST`, `PUT`, `DELETE /api/v1/admin/siem/sinks` | Audit | `SiemSinkService` | +| `GET`, `POST /api/v1/admin/siem/sinks`, `PUT`, `DELETE .../{id}` | Audit | `SiemService` | -Controllers live in `lib/Controller/Admin/` and hold no logic beyond parameter mapping, so the screen and the API share one code path. Responses use the same shapes and error envelope as the existing endpoints (org ADR-050). +Registering an application (`POST /applications`), reading one (`GET .../{id}`) and its lease policy were added for the Terraform provider (change `apps-terraform-provider`, D5): an administrator's registration is active at once. Suite reinstatement is out of v1: since keepiq#865 it carries `#[PasswordConfirmationRequired]` like force revocation (see D4). + +Controllers (`AdminIndexController`, `AdminPeopleController`, `AdminApplicationController`, `AdminAuditController` in `lib/Controller/`; Nextcloud resolves route names to that namespace only) hold no logic beyond parameter mapping. Every one except the index carries one `#[AuthorizedAdminSetting(::class)]`, so a caller outside the area is refused in the middleware. The policies pair reuses the area settings methods under a route `postfix`, so the screen and the API share one code path. Responses use the same shapes and error envelope as the existing endpoints (org ADR-050). Alternative considered: document the existing internal routes as the public API. Rejected: their paths are inconsistent (`/api/settings/admin` next to `/api/v1/...`) and some mix owner and admin behaviour behind one path, so freezing them would freeze that. @@ -62,7 +65,7 @@ Alternative considered: admin tokens as Keepiq applications with admin scopes ov ### D4: No force revocation over the API -`POST /api/v1/suites/{id}/force-revoke` carries `#[PasswordConfirmationRequired]` (ADR-005): the administrator re-confirms their own password at that moment. A stored app password cannot give that proof, so the API leaves force revocation out and the index says so. Reinstatement has no such guard and is in. +`POST /api/v1/suites/{id}/force-revoke` carries `#[PasswordConfirmationRequired]` (ADR-005): the administrator re-confirms their own password at that moment. A stored app password cannot give that proof, so the API leaves force revocation out and the index says so. Since keepiq#865 reinstatement carries the same guard, so it is out too (POLICY.md, lane G2). ### D5: v1 only grows diff --git a/openspec/changes/admin-public-api/specs/admin-api/spec.md b/openspec/changes/admin-public-api/specs/admin-api/spec.md index c7cd6aa18..71609d3c8 100644 --- a/openspec/changes/admin-public-api/specs/admin-api/spec.md +++ b/openspec/changes/admin-public-api/specs/admin-api/spec.md @@ -28,7 +28,7 @@ Every admin API endpoint MUST accept a Nextcloud session or a Nextcloud app pass ### Requirement: Admin API covers the administration jobs -The v1 admin API MUST offer: the member overview, offboarding, suite listing and reinstatement, reading and updating policies, listing, approving, rejecting and deleting applications, reading audit events, generating and reading compliance reports, and managing SIEM sinks. Each endpoint MUST call the same service the admin screen calls. +The v1 admin API MUST offer: the member overview, offboarding, suite listing, reading and updating policies, registering, listing, reading, approving, rejecting and deleting applications and setting their lease policy, reading audit events, generating and reading compliance reports, and managing SIEM sinks. Each endpoint MUST call the same service the admin screen calls. #### Scenario: Script approves a pending application @@ -39,7 +39,7 @@ The v1 admin API MUST offer: the member overview, offboarding, suite listing and ### Requirement: Admin API returns metadata only -No admin API response MUST contain a private key blob, a secret value, secret ciphertext or a SIEM sink credential in plain form. Suite force revocation MUST NOT be reachable through the admin API, because it requires a fresh password confirmation. +No admin API response MUST contain a private key blob, a secret value, secret ciphertext or a SIEM sink credential in plain form. Suite force revocation and suite reinstatement MUST NOT be reachable through the admin API, because both require a fresh password confirmation. #### Scenario: Suite listing carries no key material diff --git a/openspec/changes/admin-public-api/tasks.md b/openspec/changes/admin-public-api/tasks.md index 75fcbf820..bc06edb63 100644 --- a/openspec/changes/admin-public-api/tasks.md +++ b/openspec/changes/admin-public-api/tasks.md @@ -1,21 +1,21 @@ ## 1. Endpoints -- [ ] 1.1 Add `lib/Controller/Admin/AdminIndexController.php` with `GET /api/v1/admin` returning `apiVersion`, the served versions and every path. Verify with a PHPUnit test for the payload and the route-reachability hydra gate. -- [ ] 1.2 Add the People endpoints: members, offboarding, suite list and reinstate, each guarded by the People area. Verify with PHPUnit tests for success and for a refused Audit-only user. -- [ ] 1.3 Add the Policies endpoints (`GET`, `PUT /api/v1/admin/policies`) on `AdminSettingsService`. Verify with PHPUnit tests that validation errors match the admin screen's errors. -- [ ] 1.4 Add the Applications endpoints (list, approve, reject, delete). Verify with PHPUnit tests for each status change and the no-admin-idor hydra gate. -- [ ] 1.5 Add the Audit endpoints (audit events, compliance reports, SIEM sinks). Verify with PHPUnit tests, including that no response carries a SIEM sink secret in plain form. -- [ ] 1.6 Add `#[UserRateLimit]` to every write endpoint and leave force revocation out. Verify with a PHPUnit test that no `/api/v1/admin` route maps to `forceRevoke`. +- [x] 1.1 Add `lib/Controller/AdminIndexController.php` with `GET /api/v1/admin` returning `apiVersion`, the served versions and every path. Verify with a PHPUnit test for the payload and the route-reachability hydra gate. +- [x] 1.2 (offboarding and suite list in `AdminPeopleController`; members is `MemberOverviewController::index()` from #965, People-guarded, documented and in the index; reinstate left out, it needs a fresh password since keepiq#865) Add the People endpoints: members, offboarding, suite list and reinstate, each guarded by the People area. Verify with PHPUnit tests for success and for a refused Audit-only user. +- [x] 1.3 Add the Policies endpoints (`GET`, `PUT /api/v1/admin/policies`) on `AdminSettingsService`. Verify with PHPUnit tests that validation errors match the admin screen's errors. +- [x] 1.4 Add the Applications endpoints (list, approve, reject, delete). Verify with PHPUnit tests for each status change and the no-admin-idor hydra gate. +- [x] 1.5 Add the Audit endpoints (audit events, compliance reports, SIEM sinks). Verify with PHPUnit tests, including that no response carries a SIEM sink secret in plain form. +- [x] 1.6 Add `#[UserRateLimit]` to every write endpoint and leave force revocation out. Verify with a PHPUnit test that no `/api/v1/admin` route maps to `forceRevoke`. ## 2. Contract -- [ ] 2.1 Write `docs/api/admin-v1.openapi.json` for every v1 path, including HTTP Basic with the `OCS-APIRequest` header. Verify with an OpenAPI 3.1 schema lint in CI. -- [ ] 2.2 Add `tests/Unit/Contract/AdminApiContractTest.php` that compares the document with `appinfo/routes.php`. Verify by removing one path locally and watching the test fail. -- [ ] 2.3 Add `tests/integration/admin-api.postman_collection.json` and its seed step (service account, delegation, app password). Verify with `tests/integration/run-newman.sh` in the CI Newman job. +- [x] 2.1 Write `docs/api/admin-v1.openapi.json` for every v1 path, including HTTP Basic with the `OCS-APIRequest` header. Verify with an OpenAPI 3.1 schema lint in CI. +- [x] 2.2 Add `tests/Unit/Contract/AdminApiContractTest.php` that compares the document with `appinfo/routes.php`. Verify by removing one path locally and watching the test fail. +- [ ] 2.3 (written: the collection seeds its own Audit-only account through the provisioning API and `authorizedgroups/saveSettings`; not run locally, CI Newman owed) Add `tests/integration/admin-api.postman_collection.json` and its seed step (service account, delegation, app password). Verify with `tests/integration/run-newman.sh` in the CI Newman job. ## 3. Documentation -- [ ] 3.1 Add an "Admin API" page under `docs/tutorials/admin/` that renders the document and explains the service account setup and the versioning rule. Verify with the docs build (`npm run build` in `docs/`). +- [x] 3.1 Add an "Admin API" page under `docs/tutorials/admin/` that renders the document and explains the service account setup and the versioning rule. Verify with the docs build (`npm run build` in `docs/`). ## Acceptance criteria diff --git a/tests/Unit/AppInfo/RoutesWithoutOpenRegisterTest.php b/tests/Unit/AppInfo/RoutesWithoutOpenRegisterTest.php index 8d16271ad..4fbf9e519 100644 --- a/tests/Unit/AppInfo/RoutesWithoutOpenRegisterTest.php +++ b/tests/Unit/AppInfo/RoutesWithoutOpenRegisterTest.php @@ -74,7 +74,10 @@ public function testRoutesFileLoadsWithoutOpenRegister(): void { $this->assertContains('dashboard#page', $names); $this->assertContains('secret#index', $names); $this->assertSame('dashboard#catchAll', end($names)); - $this->assertSame(count($names), count(array_unique($names)), 'Every route name registers once.'); + // Nextcloud names a route by `name` plus its optional `postfix` + // (RouteParser), so two URLs may reach one method with distinct postfixes. + $registered = array_map(static fn (array $route): string => $route['name'] . ($route['postfix'] ?? ''), $routes['routes']); + $this->assertSame(count($registered), count(array_unique($registered)), 'Every route name registers once.'); }//end testRoutesFileLoadsWithoutOpenRegister() /** diff --git a/tests/Unit/Contract/AdminApiContractTest.php b/tests/Unit/Contract/AdminApiContractTest.php new file mode 100644 index 000000000..cdc2ba0cc --- /dev/null +++ b/tests/Unit/Contract/AdminApiContractTest.php @@ -0,0 +1,236 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Tests\Unit\Contract; + +use OCA\Keepiq\Controller\AdminIndexController; +use OCA\Keepiq\Service\AdminAreaAuthorizer; +use OCA\Keepiq\Service\MemberOverviewService; +use OCA\Keepiq\Settings\AuditAdminSettings; +use OCA\Keepiq\Settings\PeopleAdminSettings; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * One list of v1 admin paths, three places that must agree. + */ +class AdminApiContractTest extends TestCase { + /** + * The prefix every admin API route carries. + * + * @var string + */ + private const PREFIX = '/api/v1/admin'; + + /** + * The admin routes of appinfo/routes.php, as "METHOD path" => route. + * + * @return array> + */ + private function routes(): array { + $routes = []; + foreach ((require __DIR__ . '/../../../appinfo/routes.php')['routes'] as $route) { + if (str_starts_with((string)$route['url'], self::PREFIX) === true) { + $routes[$route['verb'] . ' ' . $route['url']] = $route; + } + } + + ksort($routes); + return $routes; + }//end routes() + + /** + * The documented operations, as "METHOD path". + * + * @return string[] + */ + private function documented(): array { + $document = json_decode((string)file_get_contents(__DIR__ . '/../../../docs/api/admin-v1.openapi.json'), true); + $operations = []; + foreach ($document['paths'] as $path => $methods) { + foreach (array_keys($methods) as $method) { + $operations[] = strtoupper($method) . ' ' . $path; + } + } + + sort($operations); + return $operations; + }//end documented() + + /** + * The controller method a route reaches. + * + * @param array $route The route + * + * @return ReflectionMethod + */ + private function method(array $route): ReflectionMethod { + [$controller, $action] = explode('#', (string)$route['name']); + $class = 'OCA\\Keepiq\\Controller\\' . ucfirst($controller) . 'Controller'; + + return new ReflectionMethod($class, $action); + }//end method() + + /** + * The OpenAPI document and the routes describe the same operations; the + * test names whatever is missing on either side. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#2.2 + */ + public function testTheDocumentMatchesTheRoutes(): void { + $routes = array_keys($this->routes()); + $documented = $this->documented(); + + $this->assertSame([], array_values(array_diff($routes, $documented)), 'routes missing from docs/api/admin-v1.openapi.json'); + $this->assertSame([], array_values(array_diff($documented, $routes)), 'documented operations without a route'); + $this->assertNotEmpty($routes); + }//end testTheDocumentMatchesTheRoutes() + + /** + * The index lists exactly the routed operations. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.1 + */ + public function testTheIndexListsEveryRoute(): void { + $listed = array_map(static fn (array $path): string => $path['method'] . ' ' . $path['path'], AdminIndexController::PATHS); + sort($listed); + + $this->assertSame(array_keys($this->routes()), $listed); + }//end testTheIndexListsEveryRoute() + + /** + * Every admin route except the index is guarded by exactly one area in + * the middleware, is never public, needs no fresh password, and the area + * in the index matches the guard. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.6 + */ + public function testEveryRouteIsGuardedByTheAreaTheIndexNames(): void { + $areaOf = []; + foreach (AdminIndexController::PATHS as $path) { + $areaOf[$path['method'] . ' ' . $path['path']] = $path['area']; + } + + foreach ($this->routes() as $key => $route) { + $method = $this->method(route: $route); + $this->assertSame([], $method->getAttributes(PublicPage::class), $key); + $this->assertSame([], $method->getAttributes(PasswordConfirmationRequired::class), $key . ' needs a fresh password, which a script cannot give'); + $this->assertStringNotContainsString('forceRevoke', $method->getName()); + $this->assertStringNotContainsString('reinstate', $method->getName()); + + if ($key === 'GET ' . self::PREFIX) { + $this->assertNotSame([], $method->getAttributes(NoAdminRequired::class)); + continue; + } + + $guards = $method->getAttributes(AuthorizedAdminSetting::class); + $this->assertCount(1, $guards, $key); + $this->assertSame([], $method->getAttributes(NoAdminRequired::class), $key . ' must be refused in the middleware'); + $this->assertSame( + AdminAreaAuthorizer::AREAS[$areaOf[$key]], + $guards[0]->newInstance()->getSettings(), + $key . ' is guarded by another area than the index says' + ); + } + }//end testEveryRouteIsGuardedByTheAreaTheIndexNames() + + /** + * Scenario "Audit token cannot change policies": under Nextcloud's + * middleware rule an Audit-only account reaches the audit routes and no + * other admin route. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.2 + */ + public function testAnAuditOnlyAccountReachesOnlyTheAuditRoutes(): void { + $reached = []; + foreach ($this->routes() as $key => $route) { + foreach ($this->method(route: $route)->getAttributes(AuthorizedAdminSetting::class) as $guard) { + if ($guard->newInstance()->getSettings() === AuditAdminSettings::class) { + $reached[] = $key; + } + } + } + + $this->assertNotContains('PUT ' . self::PREFIX . '/policies', $reached); + $this->assertContains('GET ' . self::PREFIX . '/audit', $reached); + foreach ($reached as $key) { + $this->assertMatchesRegularExpression('~^[A-Z]+ /api/v1/admin/(audit|compliance|siem)~', $key); + } + }//end testAnAuditOnlyAccountReachesOnlyTheAuditRoutes() + + /** + * `GET /api/v1/admin/members` (task 1.2): People-guarded in the + * middleware, refused to an Audit-only account, documented with the + * controller's own query parameters and the member row fields. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.2 + */ + public function testTheMembersOperation(): void { + $key = 'GET ' . self::PREFIX . '/members'; + $routes = $this->routes(); + $this->assertArrayHasKey($key, $routes); + + $method = $this->method(route: $routes[$key]); + $guards = $method->getAttributes(AuthorizedAdminSetting::class); + $this->assertCount(1, $guards); + $this->assertSame(PeopleAdminSettings::class, $guards[0]->newInstance()->getSettings()); + $this->assertNotSame(AuditAdminSettings::class, $guards[0]->newInstance()->getSettings()); + + $document = json_decode((string)file_get_contents(__DIR__ . '/../../../docs/api/admin-v1.openapi.json'), true); + $operation = $document['paths'][self::PREFIX . '/members']['get']; + $this->assertSame('people', $operation['x-keepiq-area']); + + $documented = []; + foreach ($operation['parameters'] as $parameter) { + if (isset($parameter['in']) === true && $parameter['in'] === 'query') { + $documented[] = $parameter['name']; + } + } + + $this->assertSame(array_map(static fn (\ReflectionParameter $p): string => $p->getName(), $method->getParameters()), $documented); + + $row = array_keys($operation['responses']['200']['content']['application/json']['schema']['properties']['results']['items']['properties']); + $this->assertSame( + ['userId', 'displayName', 'enabled', 'vaultStatus', 'activeSuiteId', 'suiteCreatedAt', 'secretCount', 'teamFolderMemberships', 'hasEmergencyContact'], + $row + ); + // The row the service builds carries exactly these keys. + $source = (string)file_get_contents(__DIR__ . '/../../../lib/Service/MemberOverviewService.php'); + foreach ($row as $field) { + $this->assertStringContainsString("'" . $field . "' =>", $source, $field . ' is documented but not built'); + } + + $this->assertSame(MemberOverviewService::STATUSES, array_values(array_filter($operation['parameters'][1]['schema']['enum']))); + }//end testTheMembersOperation() +}//end class diff --git a/tests/Unit/Controller/AdminApplicationControllerTest.php b/tests/Unit/Controller/AdminApplicationControllerTest.php new file mode 100644 index 000000000..ebdea0e68 --- /dev/null +++ b/tests/Unit/Controller/AdminApplicationControllerTest.php @@ -0,0 +1,244 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Tests\Unit\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\Controller\AdminApplicationController; +use OCA\Keepiq\Db\Application; +use OCA\Keepiq\Db\ApplicationMapper; +use OCA\Keepiq\Service\ApplicationService; +use OCA\Keepiq\Service\LeaseService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Each endpoint runs the screen's service as an administrator. + */ +class AdminApplicationControllerTest extends TestCase { + /** @var ApplicationService&MockObject */ + private ApplicationService $applications; + + /** @var LeaseService&MockObject */ + private LeaseService $leases; + + /** @var ApplicationMapper&MockObject */ + private ApplicationMapper $mapper; + + /** + * Fresh doubles. + * + * @return void + */ + protected function setUp(): void { + $this->applications = $this->createMock(ApplicationService::class); + $this->leases = $this->createMock(LeaseService::class); + $this->mapper = $this->createMock(ApplicationMapper::class); + }//end setUp() + + /** + * The controller, signed in as service account `svc-apps`. + * + * @return AdminApplicationController + */ + private function controller(): AdminApplicationController { + $user = $this->createStub(IUser::class); + $user->method('getUID')->willReturn('svc-apps'); + $session = $this->createStub(IUserSession::class); + $session->method('getUser')->willReturn($user); + + return new AdminApplicationController( + request: $this->createStub(IRequest::class), + applications: $this->applications, + leases: $this->leases, + applicationMapper: $this->mapper, + userSession: $session, + ); + }//end controller() + + /** + * An application row. + * + * @param string $id The id + * + * @return Application + */ + private function application(string $id): Application { + $application = new Application(); + $application->setId($id); + $application->setName('ci-runner'); + + return $application; + }//end application() + + /** + * The list is the administrator's list of every application. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + public function testIndexListsEveryApplication(): void { + $this->applications->expects($this->once())->method('listForUser')->with('svc-apps', true)->willReturn([$this->application(id: 'app-1')]); + + $data = $this->controller()->index()->getData(); + + $this->assertSame('app-1', $data[0]['id']); + }//end testIndexListsEveryApplication() + + /** + * Registration runs as an administrator and answers 201; a blank name is 400. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + public function testCreateRegistersAsAnAdministrator(): void { + $this->applications->expects($this->once())->method('register') + ->with('ci-runner', null, 'external', 'CSR', 'svc-apps', true) + ->willReturn($this->application(id: 'app-2')); + + $this->assertSame(201, $this->controller()->create(name: 'ci-runner', csr: 'CSR')->getStatus()); + $this->assertSame(400, $this->controller()->create(name: ' ')->getStatus()); + }//end testCreateRegistersAsAnAdministrator() + + /** + * Scenario "Script approves a pending application": the caller is recorded + * as approver; a refusal is 400. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + public function testApproveRecordsTheCallerAsApprover(): void { + $this->applications->expects($this->exactly(2))->method('approve') + ->with('app-1', 'svc-apps', true) + ->willReturnOnConsecutiveCalls($this->application(id: 'app-1'), $this->throwException(new InvalidArgumentException('Application is not pending'))); + + $this->assertSame(200, $this->controller()->approve(id: 'app-1')->getStatus()); + $this->assertSame(400, $this->controller()->approve(id: 'app-1')->getStatus()); + }//end testApproveRecordsTheCallerAsApprover() + + /** + * Reject runs the service as the caller. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + public function testRejectRunsTheService(): void { + $this->applications->expects($this->once())->method('reject')->with('app-1', 'svc-apps', true); + + $this->assertSame(['status' => 'rejected', 'id' => 'app-1'], $this->controller()->reject(id: 'app-1')->getData()); + }//end testRejectRunsTheService() + + /** + * Show and delete answer 404 for an unknown application. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + public function testUnknownApplicationsAre404(): void { + $this->applications->method('get')->willThrowException(new InvalidArgumentException('Application not found')); + $this->applications->method('delete')->willThrowException(new InvalidArgumentException('Application not found')); + + $this->assertSame(404, $this->controller()->show(id: 'nope')->getStatus()); + $this->assertSame(404, $this->controller()->destroy(id: 'nope')->getStatus()); + }//end testUnknownApplicationsAre404() + + /** + * Delete removes the application through the service. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + public function testDestroyDeletesThroughTheService(): void { + $this->applications->expects($this->once())->method('delete')->with('app-1', true); + + $this->assertSame(['status' => 'deleted', 'id' => 'app-1'], $this->controller()->destroy(id: 'app-1')->getData()); + }//end testDestroyDeletesThroughTheService() + + /** + * The lease policy: 404 for an unknown application, the view for a known + * one, and a write that stores the override and returns the new view. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + public function testLeasePolicyReadAndWrite(): void { + $this->mapper->method('findById')->willReturnCallback(function (string $id): Application { + if ($id !== 'app-1') { + throw new DoesNotExistException('no'); + } + + return $this->application(id: $id); + }); + $view = ['override' => ['defaultTtl' => 600], 'effective' => ['defaultTtl' => 600]]; + $this->leases->method('policyView')->with('app-1')->willReturn($view); + $this->leases->expects($this->once())->method('setPolicyOverride')->with('app-1', 600, null, null); + + $this->assertSame(404, $this->controller()->getLeasePolicy(id: 'nope')->getStatus()); + $this->assertSame($view, $this->controller()->getLeasePolicy(id: 'app-1')->getData()); + $this->assertSame($view, $this->controller()->setLeasePolicy(id: 'app-1', defaultTtl: 600)->getData()); + $this->assertSame(404, $this->controller()->setLeasePolicy(id: 'nope', defaultTtl: 600)->getStatus()); + }//end testLeasePolicyReadAndWrite() + + /** + * A refused lease policy answers 400. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + public function testARefusedLeasePolicyIs400(): void { + $this->mapper->method('findById')->willReturn($this->application(id: 'app-1')); + $this->leases->method('setPolicyOverride')->willThrowException(new InvalidArgumentException('defaultTtl must be at least 60 seconds')); + + $this->assertSame(400, $this->controller()->setLeasePolicy(id: 'app-1', defaultTtl: 5)->getStatus()); + }//end testARefusedLeasePolicyIs400() + + /** + * Show carries the public certificate of an active application, and + * null for a pending one. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.4 + */ + public function testShowCarriesTheCertificateOfAnActiveApplication(): void { + $active = $this->application(id: 'app-1'); + $active->setStatus('active'); + $pending = $this->application(id: 'app-2'); + $pending->setStatus('pending'); + $this->applications->method('get')->willReturnCallback( + static fn (string $id): Application => ($id === 'app-1' ? $active : $pending) + ); + $this->applications->expects($this->once())->method('getCertificate')->with('app-1')->willReturn('-----BEGIN CERTIFICATE-----'); + + $this->assertSame('-----BEGIN CERTIFICATE-----', $this->controller()->show(id: 'app-1')->getData()['certificate']); + $this->assertNull($this->controller()->show(id: 'app-2')->getData()['certificate']); + }//end testShowCarriesTheCertificateOfAnActiveApplication() +}//end class diff --git a/tests/Unit/Controller/AdminAuditControllerTest.php b/tests/Unit/Controller/AdminAuditControllerTest.php new file mode 100644 index 000000000..a7c201ccb --- /dev/null +++ b/tests/Unit/Controller/AdminAuditControllerTest.php @@ -0,0 +1,175 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Tests\Unit\Controller; + +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Controller\AdminAuditController; +use OCA\Keepiq\Db\ComplianceReport; +use OCA\Keepiq\Db\SiemSink; +use OCA\Keepiq\Service\AuditService; +use OCA\Keepiq\Service\ComplianceReportService; +use OCA\Keepiq\Service\SiemService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Audit events, compliance reports and SIEM sinks through the admin API. + */ +class AdminAuditControllerTest extends TestCase { + /** @var AuditService&MockObject */ + private AuditService $audit; + + /** @var ComplianceReportService&MockObject */ + private ComplianceReportService $reports; + + /** @var SiemService&MockObject */ + private SiemService $siem; + + /** + * Fresh doubles. + * + * @return void + */ + protected function setUp(): void { + $this->audit = $this->createMock(AuditService::class); + $this->reports = $this->createMock(ComplianceReportService::class); + $this->siem = $this->createMock(SiemService::class); + }//end setUp() + + /** + * The controller, signed in as `svc-audit`. + * + * @param array $params The request parameters + * + * @return AdminAuditController + */ + private function controller(array $params = []): AdminAuditController { + $user = $this->createStub(IUser::class); + $user->method('getUID')->willReturn('svc-audit'); + $session = $this->createStub(IUserSession::class); + $session->method('getUser')->willReturn($user); + $request = $this->createStub(IRequest::class); + $request->method('getParam')->willReturnCallback(static fn (string $key, mixed $default = null): mixed => $params[$key] ?? $default); + $request->method('getParams')->willReturn($params); + + return new AdminAuditController( + request: $request, + audit: $this->audit, + reports: $this->reports, + siem: $this->siem, + userSession: $session, + ); + }//end controller() + + /** + * Audit events pass every filter to the screen's query. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + public function testEventsPassTheFilters(): void { + $page = ['results' => [], 'total' => 0]; + $this->audit->expects($this->once())->method('adminQuery') + ->with(['eventType' => 'share.granted', 'actor' => 'alice', 'objectType' => null, 'objectId' => null, 'from' => '2026-10-01', 'to' => null], 2, 10) + ->willReturn($page); + + $response = $this->controller()->events(eventType: 'share.granted', actor: 'alice', from: '2026-10-01', page: 2, limit: 10); + + $this->assertSame($page, $response->getData()); + }//end testEventsPassTheFilters() + + /** + * Reports list their metadata, generation is 201, an unknown report is 404. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + public function testComplianceReports(): void { + $report = new ComplianceReport(); + $report->setId('rep-1'); + $report->setGeneratedBy('svc-audit'); + $report->setGeneratedAt(new DateTime('2026-10-02T10:00:00+00:00')); + $report->setAppVersion('0.3.4'); + $this->reports->method('listReports')->willReturn([$report]); + $this->reports->expects($this->once())->method('generate')->with('svc-audit')->willReturn($report); + $this->reports->method('getReport')->willThrowException(new DoesNotExistException('no')); + + $list = $this->controller()->reports()->getData(); + $this->assertSame(['id' => 'rep-1', 'generatedBy' => 'svc-audit', 'generatedAt' => '2026-10-02T10:00:00+00:00', 'appVersion' => '0.3.4'], $list[0]); + $this->assertSame(201, $this->controller()->generateReport()->getStatus()); + $this->assertSame(404, $this->controller()->showReport(id: 'nope')->getStatus()); + }//end testComplianceReports() + + /** + * A sink's HMAC secret and connector credential never leave the server + * (spec "Admin API returns metadata only"). + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + public function testSinksCarryNoSecret(): void { + $sink = new SiemSink(); + $sink->setId('sink-1'); + $sink->setName('soc'); + $sink->setType('webhook'); + $sink->setEndpoint('https://soc.example/hook'); + $sink->setHmacSecretEnc('ENCRYPTED-HMAC'); + $sink->setCredentialEnc('ENCRYPTED-CREDENTIAL'); + $this->siem->method('listSinks')->willReturn([$sink]); + + $json = (string)json_encode($this->controller()->sinks()->getData()); + + $this->assertStringContainsString('sink-1', $json); + $this->assertStringNotContainsString('ENCRYPTED-HMAC', $json); + $this->assertStringNotContainsString('ENCRYPTED-CREDENTIAL', $json); + }//end testSinksCarryNoSecret() + + /** + * Sink writes: a refused create is 400, an unknown sink is 404 on update + * and delete, a delete reports it. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.5 + */ + public function testSinkWrites(): void { + $this->siem->method('createSink')->willThrowException(new InvalidArgumentException('endpoint is required')); + $this->siem->method('updateSink')->willThrowException(new DoesNotExistException('no')); + $this->siem->expects($this->exactly(2))->method('deleteSink')->with('svc-audit', $this->anything()) + ->willReturnCallback(static function (string $admin, string $id): void { + if ($id === 'nope') { + throw new DoesNotExistException('no'); + } + }); + + $this->assertSame(400, $this->controller(params: ['name' => 'x'])->createSink()->getStatus()); + $this->assertSame(404, $this->controller()->updateSink(id: 'nope')->getStatus()); + $this->assertSame(['deleted' => true], $this->controller()->destroySink(id: 'sink-1')->getData()); + $this->assertSame(404, $this->controller()->destroySink(id: 'nope')->getStatus()); + }//end testSinkWrites() +}//end class diff --git a/tests/Unit/Controller/AdminIndexControllerTest.php b/tests/Unit/Controller/AdminIndexControllerTest.php new file mode 100644 index 000000000..24424f77d --- /dev/null +++ b/tests/Unit/Controller/AdminIndexControllerTest.php @@ -0,0 +1,120 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Tests\Unit\Controller; + +use OCA\Keepiq\Controller\AdminIndexController; +use OCA\Keepiq\Settings\AuditAdminSettings; +use OCA\Keepiq\Tests\Support\AdminAreaFixture; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; + +/** + * The index answers area holders and nobody else. + */ +class AdminIndexControllerTest extends TestCase { + use AdminAreaFixture; + + /** + * The controller for a caller. + * + * @param string|null $uid The caller, null for anonymous + * @param bool $isAdmin Whether the caller is an instance admin + * + * @return AdminIndexController + */ + private function controller(?string $uid, bool $isAdmin = false): AdminIndexController { + $session = $this->createStub(IUserSession::class); + $user = null; + if ($uid !== null) { + $user = $this->createStub(IUser::class); + $user->method('getUID')->willReturn($uid); + } + + $session->method('getUser')->willReturn($user); + $groups = $this->createStub(IGroupManager::class); + $groups->method('isAdmin')->willReturn($isAdmin); + + return new AdminIndexController( + request: $this->createStub(IRequest::class), + userSession: $session, + areas: $this->areaAuthorizer(groupManager: $groups), + ); + }//end controller() + + /** + * An admin gets version 1, every area and every path. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.1 + */ + public function testAnAdminGetsTheIndex(): void { + $data = $this->controller(uid: 'root', isAdmin: true)->index()->getData(); + + $this->assertSame(1, $data['apiVersion']); + $this->assertSame(['v1'], $data['versions']); + $this->assertSame(['general', 'policies', 'applications', 'people', 'audit'], $data['areas']); + $this->assertSame(AdminIndexController::PATHS, $data['paths']); + }//end testAnAdminGetsTheIndex() + + /** + * An Audit-only service account gets the index with its one area. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.1 + */ + public function testAnAuditHolderGetsTheIndex(): void { + $this->delegatedAreas = [AuditAdminSettings::class]; + $response = $this->controller(uid: 'svc-audit')->index(); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame(['audit'], $response->getData()['areas']); + }//end testAnAuditHolderGetsTheIndex() + + /** + * A user without any area, and an anonymous caller, are refused. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.1 + */ + public function testNoAreaIsRefused(): void { + $this->assertSame(403, $this->controller(uid: 'bob')->index()->getStatus()); + $this->assertSame(403, $this->controller(uid: null)->index()->getStatus()); + }//end testNoAreaIsRefused() + + /** + * The path list offers neither force revocation nor reinstatement. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.6 + */ + public function testNoPathRevokesOrReinstatesASuite(): void { + foreach (AdminIndexController::PATHS as $path) { + $this->assertStringNotContainsString('force-revoke', $path['path']); + $this->assertStringNotContainsString('reinstate', $path['path']); + } + }//end testNoPathRevokesOrReinstatesASuite() +}//end class diff --git a/tests/Unit/Controller/AdminPeopleControllerTest.php b/tests/Unit/Controller/AdminPeopleControllerTest.php new file mode 100644 index 000000000..437265f8f --- /dev/null +++ b/tests/Unit/Controller/AdminPeopleControllerTest.php @@ -0,0 +1,133 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Tests\Unit\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\Controller\AdminPeopleController; +use OCA\Keepiq\Db\EncryptionSuite; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Service\TeamFolderService; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Suite listing carries metadata only; offboarding runs the screen's service. + */ +class AdminPeopleControllerTest extends TestCase { + /** @var EncryptionSuiteMapper&MockObject */ + private EncryptionSuiteMapper $suites; + + /** @var TeamFolderService&MockObject */ + private TeamFolderService $teamFolders; + + /** + * Fresh doubles. + * + * @return void + */ + protected function setUp(): void { + $this->suites = $this->createMock(EncryptionSuiteMapper::class); + $this->teamFolders = $this->createMock(TeamFolderService::class); + }//end setUp() + + /** + * The controller, signed in as `helpdesk`. + * + * @return AdminPeopleController + */ + private function controller(): AdminPeopleController { + $user = $this->createStub(IUser::class); + $user->method('getUID')->willReturn('helpdesk'); + $session = $this->createStub(IUserSession::class); + $session->method('getUser')->willReturn($user); + + return new AdminPeopleController( + request: $this->createStub(IRequest::class), + suites: $this->suites, + teamFolders: $this->teamFolders, + userSession: $session, + ); + }//end controller() + + /** + * A suite row has id, owner, status and dates, and never the private key + * or the certificate (spec "Suite listing carries no key material"). + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.2 + */ + public function testSuitesCarryNoKeyMaterial(): void { + $suite = new EncryptionSuite(); + $suite->setId('suite-1'); + $suite->setOwnerType('user'); + $suite->setOwnerId('alice'); + $suite->setStatus('active'); + $suite->setPrivateKey('ENCRYPTED-PRIVATE-KEY-BLOB'); + $suite->setCertificate('-----BEGIN CERTIFICATE-----'); + $this->suites->expects($this->once())->method('findAllActiveWithLimit')->with(500, 0)->willReturn([$suite]); + + $data = $this->controller()->suites(limit: 9999, offset: -3)->getData(); + + $this->assertSame(500, $data['limit']); + $this->assertSame(0, $data['offset']); + $this->assertSame('suite-1', $data['results'][0]['id']); + $this->assertSame('alice', $data['results'][0]['ownerId']); + $this->assertArrayNotHasKey('privateKey', $data['results'][0]); + $this->assertArrayNotHasKey('certificate', $data['results'][0]); + $this->assertStringNotContainsString('PRIVATE-KEY', (string)json_encode($data)); + }//end testSuitesCarryNoKeyMaterial() + + /** + * Offboarding runs the screen's service as the caller and returns its summary. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.2 + */ + public function testOffboardingRunsTheScreensService(): void { + $summary = ['revoked' => 2, 'removedMemberships' => 1, 'transferred' => 3, 'skipped' => []]; + $this->teamFolders->expects($this->once())->method('offboard')->with('carol', 'dave', 'helpdesk')->willReturn($summary); + + $response = $this->controller()->offboard(leavingUserId: 'carol', successorUserId: 'dave'); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame($summary, $response->getData()); + }//end testOffboardingRunsTheScreensService() + + /** + * A refused offboarding answers 400 with the service's message. + * + * @return void + * + * @spec openspec/changes/admin-public-api/tasks.md#1.2 + */ + public function testARefusedOffboardingAnswers400(): void { + $this->teamFolders->method('offboard')->willThrowException(new InvalidArgumentException('Successor must differ from the leaving user')); + + $response = $this->controller()->offboard(leavingUserId: 'carol', successorUserId: 'carol'); + + $this->assertSame(400, $response->getStatus()); + $this->assertSame(['message' => 'Successor must differ from the leaving user'], $response->getData()); + }//end testARefusedOffboardingAnswers400() +}//end class diff --git a/tests/integration/admin-api.postman_collection.json b/tests/integration/admin-api.postman_collection.json new file mode 100644 index 000000000..3a0d70b7f --- /dev/null +++ b/tests/integration/admin-api.postman_collection.json @@ -0,0 +1,1483 @@ +{ + "info": { + "name": "Keepiq admin API v1 (admin-public-api)", + "description": "Contract for the versioned admin API under /api/v1/admin. Seeds an Audit-only service account through Nextcloud's provisioning API and admin delegation, checks it reaches the Audit area and is refused every other area, and checks the admin endpoints carry no key material. Admin requests use {{baseUrl}}; the service account uses {{noAuthBase}}, a different host, so the two never share a session cookie.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "noauth" + }, + "variable": [ + { + "key": "auditorUser", + "value": "keepiq-newman-auditor" + }, + { + "key": "auditorPass", + "value": "Kq-newman-audit-2026-area" + }, + { + "key": "auditorGroup", + "value": "keepiq-newman-auditors" + } + ], + "item": [ + { + "name": "0. Seed: an Audit-only service account", + "item": [ + { + "name": "Create group keepiq-newman-auditors", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/ocs/v2.php/cloud/groups?format=json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ocs", + "v2.php", + "cloud", + "groups" + ], + "query": [ + { + "key": "format", + "value": "json" + } + ] + }, + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "groupid", + "value": "{{auditorGroup}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status in [200, 400]', () => pm.expect([200, 400]).to.include(pm.response.code));" + ] + } + } + ] + }, + { + "name": "Create user keepiq-newman-auditor", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/ocs/v2.php/cloud/users?format=json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ocs", + "v2.php", + "cloud", + "users" + ], + "query": [ + { + "key": "format", + "value": "json" + } + ] + }, + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "userid", + "value": "{{auditorUser}}" + }, + { + "key": "password", + "value": "{{auditorPass}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status in [200, 400]', () => pm.expect([200, 400]).to.include(pm.response.code));" + ] + } + } + ] + }, + { + "name": "Add the user to the group", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/ocs/v2.php/cloud/users/{{auditorUser}}/groups?format=json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ocs", + "v2.php", + "cloud", + "users", + "{{auditorUser}}", + "groups" + ], + "query": [ + { + "key": "format", + "value": "json" + } + ] + }, + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "groupid", + "value": "{{auditorGroup}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status in [200]', () => pm.expect([200]).to.include(pm.response.code));" + ] + } + } + ] + }, + { + "name": "Delegate the Audit and compliance area to the group", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/settings/authorizedgroups/saveSettings", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "settings", + "authorizedgroups", + "saveSettings" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"newGroups\": [{\"gid\": \"{{auditorGroup}}\"}], \"class\": \"OCA\\\\Keepiq\\\\Settings\\\\AuditAdminSettings\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status in [200]', () => pm.expect([200]).to.include(pm.response.code));" + ] + } + } + ] + } + ] + }, + { + "name": "1. Index", + "item": [ + { + "name": "GET /api/v1/admin as admin (200, apiVersion 1, every area)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/keepiq/api/v1/admin", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));", + "const d = pm.response.json();", + "pm.test('apiVersion 1', () => pm.expect(d.apiVersion).to.eql(1));", + "pm.test('serves v1', () => pm.expect(d.versions).to.include('v1'));", + "pm.test('an admin holds every area', () => pm.expect(d.areas).to.have.members(['general','policies','applications','people','audit']));", + "pm.test('no force revocation or reinstatement path', () => d.paths.forEach(p => { pm.expect(p.path).to.not.include('force-revoke'); pm.expect(p.path).to.not.include('reinstate'); }));" + ] + } + } + ] + }, + { + "name": "GET /api/v1/admin as the auditor (200, audit only)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{auditorUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{auditorPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{noAuthBase}}/index.php/apps/keepiq/api/v1/admin", + "host": [ + "{{noAuthBase}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));", + "pm.test('holds only audit', () => pm.expect(pm.response.json().areas).to.eql(['audit']));" + ] + } + } + ] + } + ] + }, + { + "name": "2. Audit area", + "item": [ + { + "name": "GET audit events as the auditor (200)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{auditorUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{auditorPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{noAuthBase}}/index.php/apps/keepiq/api/v1/admin/audit?limit=5", + "host": [ + "{{noAuthBase}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "audit" + ], + "query": [ + { + "key": "limit", + "value": "5" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "GET compliance reports as the auditor (200)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{auditorUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{auditorPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{noAuthBase}}/index.php/apps/keepiq/api/v1/admin/compliance/reports", + "host": [ + "{{noAuthBase}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "compliance", + "reports" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));", + "pm.test('a list', () => pm.expect(pm.response.json()).to.be.an('array'));" + ] + } + } + ] + }, + { + "name": "GET SIEM sinks as the auditor (200, no secrets)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{auditorUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{auditorPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{noAuthBase}}/index.php/apps/keepiq/api/v1/admin/siem/sinks", + "host": [ + "{{noAuthBase}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "siem", + "sinks" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));", + "pm.test('no sink carries a secret', () => pm.response.json().forEach(s => { pm.expect(s).to.not.have.property('hmacSecret'); pm.expect(s).to.not.have.property('credential'); }));" + ] + } + } + ] + } + ] + }, + { + "name": "3. Refusals for the Audit-only account", + "item": [ + { + "name": "PUT policies as the auditor (403)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{auditorUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{auditorPass}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{noAuthBase}}/index.php/apps/keepiq/api/v1/admin/policies", + "host": [ + "{{noAuthBase}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "policies" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"min_password_length\": 14}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('403', () => pm.response.to.have.status(403));" + ] + } + } + ] + }, + { + "name": "GET policies as the auditor (403)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{auditorUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{auditorPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{noAuthBase}}/index.php/apps/keepiq/api/v1/admin/policies", + "host": [ + "{{noAuthBase}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "policies" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('403', () => pm.response.to.have.status(403));" + ] + } + } + ] + }, + { + "name": "GET suites as the auditor (403)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{auditorUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{auditorPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{noAuthBase}}/index.php/apps/keepiq/api/v1/admin/suites", + "host": [ + "{{noAuthBase}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "suites" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('403', () => pm.response.to.have.status(403));" + ] + } + } + ] + }, + { + "name": "GET applications as the auditor (403)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{auditorUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{auditorPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{noAuthBase}}/index.php/apps/keepiq/api/v1/admin/applications", + "host": [ + "{{noAuthBase}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "applications" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('403', () => pm.response.to.have.status(403));" + ] + } + } + ] + }, + { + "name": "POST offboarding as the auditor (403)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{auditorUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{auditorPass}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{noAuthBase}}/index.php/apps/keepiq/api/v1/admin/offboarding", + "host": [ + "{{noAuthBase}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "offboarding" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"leavingUserId\": \"nobody\", \"successorUserId\": \"nobody2\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('403', () => pm.response.to.have.status(403));" + ] + } + } + ] + } + ] + }, + { + "name": "4. Other areas as admin", + "item": [ + { + "name": "GET policies (200)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/keepiq/api/v1/admin/policies", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "policies" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));", + "pm.test('a policies key', () => pm.expect(pm.response.json()).to.have.property('min_password_length'));", + "pm.test('no key of another area', () => pm.expect(pm.response.json()).to.not.have.property('audit_retention_days'));" + ] + } + } + ] + }, + { + "name": "PUT policies with a key of another area (400)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/keepiq/api/v1/admin/policies", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "policies" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"audit_retention_days\": 400}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('400', () => pm.response.to.have.status(400));" + ] + } + } + ] + }, + { + "name": "GET suites (200, no key material)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/keepiq/api/v1/admin/suites?limit=10", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "suites" + ], + "query": [ + { + "key": "limit", + "value": "10" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));", + "const d = pm.response.json();", + "pm.test('paged', () => pm.expect(d.limit).to.eql(10));", + "pm.test('no private key or certificate', () => d.results.forEach(s => { pm.expect(s).to.not.have.property('privateKey'); pm.expect(s).to.not.have.property('certificate'); }));" + ] + } + } + ] + }, + { + "name": "GET applications (200)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/keepiq/api/v1/admin/applications", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "applications" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));", + "pm.test('a list', () => pm.expect(pm.response.json()).to.be.an('array'));" + ] + } + } + ] + }, + { + "name": "GET an unknown application (404)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/keepiq/api/v1/admin/applications/00000000-0000-4000-8000-000000000000", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "applications", + "00000000-0000-4000-8000-000000000000" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('404', () => pm.response.to.have.status(404));" + ] + } + } + ] + }, + { + "name": "GET the lease policy of an unknown application (404)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/keepiq/api/v1/admin/applications/00000000-0000-4000-8000-000000000000/lease-policy", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "applications", + "00000000-0000-4000-8000-000000000000", + "lease-policy" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('404', () => pm.response.to.have.status(404));" + ] + } + } + ] + }, + { + "name": "POST offboarding without users (400)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/keepiq/api/v1/admin/offboarding", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "keepiq", + "api", + "v1", + "admin", + "offboarding" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"leavingUserId\": \"\", \"successorUserId\": \"\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('400', () => pm.response.to.have.status(400));" + ] + } + } + ] + } + ] + }, + { + "name": "9. Cleanup", + "item": [ + { + "name": "Delete the service account", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/ocs/v2.php/cloud/users/{{auditorUser}}?format=json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ocs", + "v2.php", + "cloud", + "users", + "{{auditorUser}}" + ], + "query": [ + { + "key": "format", + "value": "json" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status in [200, 404]', () => pm.expect([200, 404]).to.include(pm.response.code));" + ] + } + } + ] + }, + { + "name": "Delete the group", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/ocs/v2.php/cloud/groups/{{auditorGroup}}?format=json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ocs", + "v2.php", + "cloud", + "groups", + "{{auditorGroup}}" + ], + "query": [ + { + "key": "format", + "value": "json" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status in [200, 404]', () => pm.expect([200, 404]).to.include(pm.response.code));" + ] + } + } + ] + } + ] + } + ] +} diff --git a/tests/integration/run-newman.sh b/tests/integration/run-newman.sh index d32d6c609..d6901cc0e 100755 --- a/tests/integration/run-newman.sh +++ b/tests/integration/run-newman.sh @@ -65,6 +65,19 @@ fi --color on \ "$@" +# Admin API v1 contract (admin-public-api). Seeds its own Audit-only service +# account through Nextcloud's provisioning API and admin delegation, and +# removes it again at the end. +"${NEWMAN[@]}" run "${SCRIPT_DIR}/admin-api.postman_collection.json" \ + --env-var "baseUrl=${BASE_URL}" \ + --env-var "noAuthBase=${NOAUTH_BASE}" \ + --env-var "adminUser=${ADMIN_USER}" \ + --env-var "adminPass=${ADMIN_PASS}" \ + --ignore-redirects \ + --reporters cli \ + --color on \ + "$@" + # Machine secret-store API contract (openconnector-secret-store-api). # The unauthenticated subset (discovery + token negatives + bearer-required) # always runs; the seeded machine flow runs only when SEEDED_APP_ID + From 6c8c766d3f38ff8bd8094112c082f62e7d1e18eb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 3 Oct 2026 23:25:46 +0200 Subject: [PATCH 141/245] docs(openspec): keep the keepiq-extension plans as a reference, mapped to Keepiq (#1003) * docs(openspec): keep the keepiq-extension plans as a reference The former repository ConductionNL/keepiq-extension planned the browser extension as a separate WXT and React project. It goes away; this copies its seven changes (163 requirements), four ADRs and notes verbatim from development @1d9f719, so nothing is lost. Not normative: Keepiq's own specs win where they differ. * docs(openspec): map every keepiq-extension requirement to Keepiq All 163 old requirements, each checked against Keepiq's specs and code at 61329cb0: 57 specified, 6 built without a spec, 53 partly built, 28 decided differently, 19 missing. Plus the four old ADRs. --- .../references/keepiq-extension/README.md | 28 ++ .../architecture/adr-001-bitwarden-parity.md | 27 ++ .../adr-002-key-lifetime-and-vault-cache.md | 99 +++++ .../adr-003-keepiq-api-contract.md | 112 +++++ .../architecture/adr-004-popup-ui-react.md | 30 ++ .../ext-accounts-and-unlock/.openspec.yaml | 2 + .../changes/ext-accounts-and-unlock/design.md | 145 +++++++ .../ext-accounts-and-unlock/proposal.md | 66 +++ .../specs/account-management/spec.md | 155 +++++++ .../specs/api-client/spec.md | 73 ++++ .../specs/vault-unlock/spec.md | 136 ++++++ .../changes/ext-accounts-and-unlock/tasks.md | 52 +++ .../changes/ext-autofill/.openspec.yaml | 2 + .../changes/ext-autofill/design.md | 117 +++++ .../changes/ext-autofill/proposal.md | 60 +++ .../ext-autofill/specs/autofill/spec.md | 187 ++++++++ .../ext-autofill/specs/login-capture/spec.md | 121 ++++++ .../changes/ext-autofill/tasks.md | 44 ++ .../changes/ext-generator/.openspec.yaml | 2 + .../changes/ext-generator/design.md | 129 ++++++ .../changes/ext-generator/proposal.md | 58 +++ .../specs/credential-generator/spec.md | 223 ++++++++++ .../changes/ext-generator/tasks.md | 49 +++ .../changes/ext-send/.openspec.yaml | 2 + .../changes/ext-send/design.md | 120 ++++++ .../changes/ext-send/proposal.md | 63 +++ .../changes/ext-send/specs/send/spec.md | 184 ++++++++ .../changes/ext-send/tasks.md | 38 ++ .../changes/ext-settings/.openspec.yaml | 2 + .../changes/ext-settings/design.md | 126 ++++++ .../changes/ext-settings/proposal.md | 66 +++ .../ext-settings/specs/settings/spec.md | 245 +++++++++++ .../changes/ext-settings/tasks.md | 46 ++ .../changes/ext-vault-browse/.openspec.yaml | 2 + .../changes/ext-vault-browse/design.md | 149 +++++++ .../changes/ext-vault-browse/proposal.md | 74 ++++ .../specs/item-detail/spec.md | 122 ++++++ .../specs/popup-shell/spec.md | 106 +++++ .../ext-vault-browse/specs/vault-list/spec.md | 150 +++++++ .../ext-vault-browse/specs/vault-sync/spec.md | 158 +++++++ .../changes/ext-vault-browse/tasks.md | 46 ++ .../changes/ext-vault-edit/.openspec.yaml | 2 + .../changes/ext-vault-edit/design.md | 130 ++++++ .../changes/ext-vault-edit/proposal.md | 77 ++++ .../specs/folder-management/spec.md | 121 ++++++ .../ext-vault-edit/specs/item-editing/spec.md | 208 +++++++++ .../changes/ext-vault-edit/tasks.md | 34 ++ .../keepiq-extension/docs/CLAUDE.md | 76 ++++ .../keepiq-extension/docs/README.md | 59 +++ .../keepiq-extension/docs/WXT-AND-BROWSERS.md | 139 ++++++ .../references/keepiq-extension/mapping.md | 406 ++++++++++++++++++ .../keepiq-extension/openspec-README.md | 28 ++ .../keepiq-extension/openspec-config.yaml | 51 +++ 53 files changed, 4947 insertions(+) create mode 100644 openspec/references/keepiq-extension/README.md create mode 100644 openspec/references/keepiq-extension/architecture/adr-001-bitwarden-parity.md create mode 100644 openspec/references/keepiq-extension/architecture/adr-002-key-lifetime-and-vault-cache.md create mode 100644 openspec/references/keepiq-extension/architecture/adr-003-keepiq-api-contract.md create mode 100644 openspec/references/keepiq-extension/architecture/adr-004-popup-ui-react.md create mode 100644 openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/.openspec.yaml create mode 100644 openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/design.md create mode 100644 openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/proposal.md create mode 100644 openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/account-management/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/api-client/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/vault-unlock/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/tasks.md create mode 100644 openspec/references/keepiq-extension/changes/ext-autofill/.openspec.yaml create mode 100644 openspec/references/keepiq-extension/changes/ext-autofill/design.md create mode 100644 openspec/references/keepiq-extension/changes/ext-autofill/proposal.md create mode 100644 openspec/references/keepiq-extension/changes/ext-autofill/specs/autofill/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-autofill/specs/login-capture/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-autofill/tasks.md create mode 100644 openspec/references/keepiq-extension/changes/ext-generator/.openspec.yaml create mode 100644 openspec/references/keepiq-extension/changes/ext-generator/design.md create mode 100644 openspec/references/keepiq-extension/changes/ext-generator/proposal.md create mode 100644 openspec/references/keepiq-extension/changes/ext-generator/specs/credential-generator/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-generator/tasks.md create mode 100644 openspec/references/keepiq-extension/changes/ext-send/.openspec.yaml create mode 100644 openspec/references/keepiq-extension/changes/ext-send/design.md create mode 100644 openspec/references/keepiq-extension/changes/ext-send/proposal.md create mode 100644 openspec/references/keepiq-extension/changes/ext-send/specs/send/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-send/tasks.md create mode 100644 openspec/references/keepiq-extension/changes/ext-settings/.openspec.yaml create mode 100644 openspec/references/keepiq-extension/changes/ext-settings/design.md create mode 100644 openspec/references/keepiq-extension/changes/ext-settings/proposal.md create mode 100644 openspec/references/keepiq-extension/changes/ext-settings/specs/settings/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-settings/tasks.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-browse/.openspec.yaml create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-browse/design.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-browse/proposal.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-browse/specs/item-detail/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-browse/specs/popup-shell/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-browse/specs/vault-list/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-browse/specs/vault-sync/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-browse/tasks.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-edit/.openspec.yaml create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-edit/design.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-edit/proposal.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-edit/specs/folder-management/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-edit/specs/item-editing/spec.md create mode 100644 openspec/references/keepiq-extension/changes/ext-vault-edit/tasks.md create mode 100644 openspec/references/keepiq-extension/docs/CLAUDE.md create mode 100644 openspec/references/keepiq-extension/docs/README.md create mode 100644 openspec/references/keepiq-extension/docs/WXT-AND-BROWSERS.md create mode 100644 openspec/references/keepiq-extension/mapping.md create mode 100644 openspec/references/keepiq-extension/openspec-README.md create mode 100644 openspec/references/keepiq-extension/openspec-config.yaml diff --git a/openspec/references/keepiq-extension/README.md b/openspec/references/keepiq-extension/README.md new file mode 100644 index 000000000..be5609005 --- /dev/null +++ b/openspec/references/keepiq-extension/README.md @@ -0,0 +1,28 @@ +# Reference: the keepiq-extension plans + +This folder keeps the specs and decisions of the former repository `ConductionNL/keepiq-extension`. That repository planned a browser extension as a separate project, built with WXT and React. It was never released. The extension that ships lives in `browser-extension/` of this repository and was built independently. + +The files here are a verbatim copy of branch `development` at commit `1d9f719` (2026-09-22). They are kept so no requirement or decision is lost when that repository goes away. + +## Status + +Nothing here is normative. The specs in `openspec/specs/` and the changes in `openspec/changes/` are the contract for this app. Where a plan here differs from what Keepiq built, Keepiq's spec wins. A requirement that Keepiq still wants becomes a change in `openspec/changes/`, not an edit here. + +`mapping.md` records, requirement by requirement, where each old requirement stands in Keepiq: specified, built without a spec, built differently, or missing. + +## What is here + +| Path | What it is | +| --- | --- | +| `changes/` | Seven planned changes with proposal, design, tasks and specs: accounts and unlock, autofill, generator, Send, settings, vault browse, vault edit. 163 requirements in all. | +| `architecture/` | Four ADRs: Bitwarden as the reference design, key lifetime and the vault cache, the Keepiq API contract, and a React popup. | +| `openspec-README.md`, `openspec-config.yaml` | The old OpenSpec README and project config. | +| `docs/` | The old repository's README, its notes on WXT and browser targets, and its CLAUDE.md. | + +## What was left out + +- The code: a WXT template with a demo background script, content script and popup. It holds no vault, unlock, autofill or API code. +- The icons: template placeholders, marked in the old repository as art to replace before release. +- `.claude/`: skills and commands generated by `openspec init`, the same ones this repository already uses. + +Once that repository is deleted, this folder is the only copy of its plans. diff --git a/openspec/references/keepiq-extension/architecture/adr-001-bitwarden-parity.md b/openspec/references/keepiq-extension/architecture/adr-001-bitwarden-parity.md new file mode 100644 index 000000000..8fc52a49e --- /dev/null +++ b/openspec/references/keepiq-extension/architecture/adr-001-bitwarden-parity.md @@ -0,0 +1,27 @@ +# ADR-001: Bitwarden browser extension is the reference design + +**Status**: accepted + +**Date**: 2026-09-22 + +## Context + +The extension has to cover a large surface (accounts, vault browsing, item detail, generator, send, settings, autofill) and the product description leaves much of it open. Users already know how a password manager extension behaves, and Bitwarden is the de facto reference for open-source ones. Re-deciding every interaction from scratch costs time and produces something users have to relearn. + +## Decision + +Where a requirement is underspecified or leaves freedom, the extension does what the Bitwarden browser extension does: layout, labels, defaults, option ranges and interaction flow. + +A deviation is allowed only when one of these forces it: + +- Keepiq's API or crypto model cannot express the Bitwarden behaviour. +- Keepiq's own spec already defines the behaviour differently (its specs win for server-side behaviour). +- The behaviour is a documented Bitwarden mistake this project chooses not to repeat (see ADR-002 on the "never lock" default). + +Every deviation is written down in the change's proposal under a "Deviations from Bitwarden" heading. + +## Consequences + +- Specs can say "as in Bitwarden" for well-known details instead of re-describing them, as long as the spec names the concrete default it adopts. +- Reviewers check a change against Bitwarden's behaviour, not against personal taste. +- Bitwarden ships features Keepiq does not have (organizations, collections, premium). Those are out of scope, not deviations. diff --git a/openspec/references/keepiq-extension/architecture/adr-002-key-lifetime-and-vault-cache.md b/openspec/references/keepiq-extension/architecture/adr-002-key-lifetime-and-vault-cache.md new file mode 100644 index 000000000..c676921a6 --- /dev/null +++ b/openspec/references/keepiq-extension/architecture/adr-002-key-lifetime-and-vault-cache.md @@ -0,0 +1,99 @@ +# ADR-002: Key lifetime and vault caching + +**Status**: accepted + +**Date**: 2026-09-22 + +This decision was first worked out from first principles, asking how a password manager extension should balance usability and security before looking at any Keepiq constraint. This file is that reasoning reconciled with Keepiq's actual crypto and API (ADR-003). Where Keepiq forces a compromise, it is named. + +## Context + +Keepiq has no vault key. The only secret-bearing key is the user's RSA-4096 private key, unwrapped from an AES envelope with a key derived from the master password at 600 000 PBKDF2 iterations (ADR-003). Every ciphertext field is RSA-OAEP to that key. The server holds no client session, no revision counter and no revocation stamp. The web app already ships an encrypted offline snapshot and treats the lock as a purely client-side state. + +"Cache or re-fetch" is two questions. Where the ciphertext lives barely matters, because it is useless without the private key. How long the private key lives is the whole decision. Re-fetching ciphertext while keeping the key in memory adds latency and a network dependency for no security gain. + +## Threat model + +Four adversaries against the data the extension holds. "Exposed" means the adversary can read it in the given state; the storage table below is checked against this, not the other way round. + +| Data | Remote server compromise | Local infostealer (file grab) | Walk-up physical access, unlocked machine | Hostile web page via content script | +| --- | --- | --- | --- | --- | +| Master password | Never (never sent) | Never stored; keylogger is out of scope for this design | Never stored | Never | +| RSA private key | Never | Not on disk (`storage.session` is memory-backed); "Never" timeout is the exception and is warned | Exposed while unlocked, for the timeout window | Never (`storage.session` is not exposed to content scripts) | +| Decrypted item fields | Never | Never on disk | Exposed for what is on screen while unlocked | One credential per fill, after explicit user choice | +| Ciphertext and plaintext metadata (names, URLs, folders) | Already held by the server | Ciphertext useless without the key; names and URLs leak which sites the user has accounts on | Names and URLs visible while unlocked | Never | +| App password | Already held by the server | Exposed; grants read and write of ciphertext via the API, not decryption | Exposed | Never | + +Consequences drawn from the table: + +- The infostealer row is why key material never touches `storage.local`: ciphertext plus app password on disk gives an attacker exactly what the server already has, no more. +- The app password is the weakest item. Nextcloud app passwords are long-lived and have no refresh flow, so the short-lived token the first-principles design asked for is not available. Mitigations: it is scoped to one Nextcloud user, revocable in one click in Nextcloud, and cannot decrypt anything or destroy the vault (destructive suite operations need a vault-key proof, ADR-003). +- The walk-up row is what the idle timeout bounds. The physical-access adversary is the one the timeout defends against; malware already running as the user can also keylog, so the timeout buys little there. +- The content script row is why URL matching and decryption happen in the background and the page receives one credential at a time. + +## Decision + +The extension caches ciphertext on disk and never persists the private key or anything that derives it. + +| Data | Lives in | Cleared by | +| --- | --- | --- | +| Master password | Nowhere. Used to derive, then dropped. | n/a | +| PBKDF2-derived unlock key | Memory, for the duration of one unlock | End of unlock | +| RSA private key | `browser.storage.session` as PKCS#8 bytes, imported to a non-extractable `CryptoKey` per worker generation | Lock, timeout, browser restart, extension reload | +| Decrypted item fields | Memory, per popup render or per fill | Popup close or fill completion | +| Suite row, secret rows (ciphertext plus plaintext metadata), folders, types | `browser.storage.local` | Logout, account removal, suite change (`encryptionSuiteId` or `unlockKeyEpoch` differs on sync) | +| App password | `browser.storage.local` | Logout, account removal, server returns 401 | +| Account list, active account id, settings | `browser.storage.local` | Account removal | + +`storage.session` is memory-backed, holds up to 10 MB, is cleared when the extension is disabled, reloaded, updated or the browser restarts, and is not exposed to content scripts unless `setAccessLevel` changes that. Do not change that default. + +The private key is stored as bytes, not as a `CryptoKey`, because `storage.session` values must be serializable. A non-extractable `CryptoKey` cannot survive an MV3 service worker restart, which happens after about 30 seconds idle. The worker re-imports the bytes into a non-extractable key on wake. This trades a small window of raw key bytes in `storage.session` for a lock that survives worker restarts. Firefox below 115 has no `storage.session`; there the key lives only in the background page's memory (MV2 pages are persistent), and the same lock rules apply. + +## Lock and logout + +Lock and logout are different states and are labelled differently everywhere in the UI. + +- **Lock** purges the private key. Ciphertext, metadata, app password and account list stay. Unlock needs the master password, or a PIN when the user enabled it, with no server round trip. +- **Logout** purges everything belonging to the account, including the app password. Returning needs the app password again. + +Defaults: vault timeout 15 minutes idle, timeout action "Lock". A browser restart always locks regardless of the setting, because `storage.session` does not survive it. Options: Immediately, 1 minute, 5 minutes, 15 minutes, 30 minutes, 1 hour, 4 hours, On system lock, On browser restart, Never, Custom. Idle means time since the user last interacted with the extension, not system sleep. "Never" carries an explicit warning because it writes key material to `storage.local`; Bitwarden shipped it as the default for years and regretted it. Whether Custom should be capped (for example at 24 hours) is open. The maximum timeout and the forced action are read from one config object so an admin policy can later clamp them. + +The extension's lock is independent of the web app's. Keepiq's web app timeout preference (`session_timeout`) is shown in settings as the server default but does not drive the extension. Logging out of the Nextcloud web UI does not lock the extension. + +## Revocation + +A cached vault on a machine the user no longer controls cannot be remotely wiped. Keepiq has no security stamp, so the compensating mechanisms are: + +- The app password. Revoking it in Nextcloud makes every request return 401. On 401 the extension purges the account's ciphertext, key and app password before doing anything else and returns the account to the logged-out state. +- The suite. If a sync returns an active suite whose `id` or `unlockKeyEpoch` differs from the cached one, the cached ciphertext is undecryptable. The extension purges the cache and the key and forces a fresh unlock and full sync. +- `blocked: true` rows. A revoked suite yields metadata-only rows. The extension shows them as blocked and never fills from them. + +The window between revocation and next contact is bounded by the sync interval. Sync runs on unlock, on popup open when the last sync is older than the interval, on a fixed interval while unlocked, and after every local write. The interval is a named constant, not an emergent property. + +## Sync + +There is no cursor. An unchanged vault must still cost one cheap call: sync first asks `GET /api/v1/secrets?sort=updated_at&direction=desc&limit=1` and compares the newest `updatedAt` and `total` with the cached snapshot; only when either differs, or the folder and type lists are stale, does it fetch `GET /api/v1/offline/manifest` and replace the local snapshot atomically. Deletions change `total`, edits and creates change `updatedAt`. When the manifest returns 403 (admin disabled) or 404, sync falls back to paginated `GET /api/v1/secrets` plus folders and types. A full fetch is acceptable because rows are small and vaults are hundreds of items, not hundreds of thousands. + +## Decryption is lazy + +Only `name`, `url`, `typeId` and `folderId` are needed for list, search and URL matching, and they are plaintext. The extension decrypts `login` for the list row's subtitle on render and `key` only on copy, fill or detail view. Nothing decrypted is written to any storage. This keeps a memory dump of the popup or worker small and matches Keepiq's own web app. + +## Content scripts + +Content scripts run in hostile pages. They never hold vault state, never receive the private key, and receive exactly one credential for one fill over a runtime message after the user explicitly picked it. URL matching happens in the background. + +## Website icons + +The extension never fetches favicons from the sites in the vault or from an icon service. Both tell someone outside the user's Keepiq server which domains the user has accounts on, and when the vault was opened. Items show a type icon instead. + +The intended future path is a favicon stored on the secret itself in Keepiq, as base64 image data set by the web app when a secret is created or its URL changes, and served with the row like any other plaintext metadata. The extension would then render icons straight from the cached snapshot with no request at all. That is a Keepiq change, not an extension one; until it lands, no icon fetching is added under any setting. + +## Clipboard + +Fill is preferred over copy. Copy auto-clears the clipboard after a configurable delay, default off in Bitwarden but offered with the same options (10 seconds to 5 minutes, or never). + +## Consequences + +- Unlock costs one PBKDF2 derivation at 600 000 iterations, roughly half a second on a laptop. Short timeouts multiply that cost and create pressure to weaken the KDF; 15 minutes is the compromise, and the option list goes no lower than 1 minute for a reason. +- Rotating the master password or the suite in the web app invalidates the extension's cache on next sync. This is expected and cheap. +- Because `PUT /api/v1/secrets/{id}` is a whole-blob, last-write-wins patch for `additionalFields`, the extension re-fetches the item before editing and writes only the fields the user changed. diff --git a/openspec/references/keepiq-extension/architecture/adr-003-keepiq-api-contract.md b/openspec/references/keepiq-extension/architecture/adr-003-keepiq-api-contract.md new file mode 100644 index 000000000..b5a5a1878 --- /dev/null +++ b/openspec/references/keepiq-extension/architecture/adr-003-keepiq-api-contract.md @@ -0,0 +1,112 @@ +# ADR-003: Keepiq API contract the extension builds against + +**Status**: accepted + +**Date**: 2026-09-22 + +Facts verified against the Keepiq app source on 2026-09-22 (branch `development`, app version 0.3.4). Specs and designs in this repo cite this file instead of repeating it. When the server changes, update this file, not the specs. + +## Transport and authentication + +- Base URL: `https:///index.php/apps/keepiq`. Every route is a plain `index.php` route. There is no `/ocs/v2.php` surface and no `{ocs:{meta,data}}` envelope. Responses are plain JSON. +- Auth: HTTP Basic with the Nextcloud username and a Nextcloud app password. No Keepiq-specific credential exists. Revoking the app password in Nextcloud security settings is the logout-all-devices mechanism. +- Every request carries `OCS-APIRequest: true`. Nextcloud core treats that header as passing the CSRF check for cookie-less requests, which is what lets the OCS-based controllers (secrets, folders, suites, sends) accept an app-password client. +- Send no cookies (`credentials: 'omit'`). A stray Nextcloud session cookie re-arms the strict cookie check and the request fails. +- No controller declares CORS. Requests go from the background script with `host_permissions` for the server origin, never from a content script. +- No server-side session, revision counter, ETag, security stamp, or push exists. The client polls. +- Nextcloud itself provides identity: `GET /ocs/v2.php/cloud/user` (with the same Basic auth and OCS header) returns the display name and email; `GET /index.php/avatar/{uid}/{size}` returns the profile picture. + +## Routes the extension uses + +All paths relative to the base URL. All require the Basic auth above unless marked public. + +| Purpose | Method and path | Notes | +| --- | --- | --- | +| Suites | `GET /api/v1/suites` | Returns a bare JSON array. Pick `status === 'active'`. Row carries `certificate` (X.509 PEM), `privateKey` (AES envelope, base64), `unlockKeyEpoch`. | +| List secrets | `GET /api/v1/secrets` | Query: `folderId`, `search`, `sort` (`name`, `url`, `created_at`, `updated_at`), `direction`, `page` (1-based), `limit` (default 50, max 100), `typeId`. Envelope `{items,total,page,limit}`. When `search` is set the other filters are ignored. | +| Full snapshot | `GET /api/v1/offline/manifest` | `{suite, secrets, folders, types, syncedAt}`. Unpaginated. 403 when the admin disabled offline caching, 404 when no active suite. `syncedAt` is a server timestamp, not a cursor. Rows never use the blocked shape here. | +| Secret CRUD | `GET/PUT/DELETE /api/v1/secrets/{id}`, `POST /api/v1/secrets` | Create takes `name` (required), `key` (required ciphertext), `url`, `typeId`, `folderId`, `login`, `additionalFields`; returns 201 with the row. `PUT` is a sparse patch: only fields present in the body change, `null` clears a field. Last write wins. Delete returns `{"status":"deleted"}`. | +| Folders | `GET/POST /api/v1/folders`, `GET /api/v1/folders/{id}/children`, `PUT/DELETE /api/v1/folders/{id}` | Delete takes `?cascade=delete|move`. | +| Types | `GET /api/v1/secret-types` | Types are rows, resolve `typeId` from here. | +| Sends | `POST/GET /api/v1/sends`, `DELETE /api/v1/sends/{id}` | See payload below. | +| Public send access | `GET /api/v1/public/sends/{token}`, `POST …/access`, `POST …/confirm`, `POST …/failure` | Public, rate limited 15/min. The extension only needs the create side; the web app renders the recipient page. | +| User settings | `GET/PUT /api/settings/user` | `session_timeout` (`session`, `10min`, `30min` or empty for admin default), notification toggles, `default_secret_type`, `offline_cache_optin`. | +| Policy | `GET /api/settings/policy` | Read-only org password policy. | +| Server generator | `POST /api/v1/generate-key` | `{length, includeSpecialCharacters, excludedCharacters, regex}` returns `{generatedKey}`. Not used by the extension; generation is client-side (ADR-001, Bitwarden parity, offline). | + +Error bodies are `{"message": ""}`. Status codes: 400 invalid input, 401 bad credentials, 403 forbidden or suite blocked, 404 missing, 423 vault write-locked during a key migration (retry later, do not treat as an auth failure). + +Out of reach for an app-password client: suite rotation, revocation and compromise recovery need a vault-key proof signed with the raw private key, and admin force-revoke needs Nextcloud password confirmation. The extension links to the web app for these. + +Routes under `/api/v1/extension/*` belong to the in-tree reference extension in the Keepiq repo and are not used by this project. + +## Secret shape + +`GET /api/v1/secrets/{id}` and list rows: + +```json +{ "id": "", "name": "GitHub", "url": "https://github.com", "typeId": "", "folderId": null, + "key": "", "login": "", "additionalFields": "", + "encryptionSuiteId": "", "ownerType": "user", "ownerId": "", "blocked": false, + "createdAt": "", "updatedAt": "", "keyUpdatedAt": null, "expiresAt": null, + "possiblyCompromisedAt": null, "tombstonedAt": null, "tombstoneReason": null } +``` + +- `name`, `url`, `typeId`, `folderId` are plaintext. Search and URL matching work without unlocking. +- `key`, `login`, `additionalFields` are ciphertext. `additionalFields` decrypts to a JSON object of name to value. Names `key`, `login`, `url` are reserved (case-insensitive). +- When the owner's suite is revoked the same endpoints return a row with `blocked: true`, `blockedReason`, and no `key`, `login` or `additionalFields`. Clients handle both shapes. +- System types: `login`, `api_key`, `ssh_key`, `certificate`, `note`, `database`, `totp`, `passkey`, `card`, `identity`. Users and admins can add more. Composite types (`totp`, `passkey`, `card`, `identity`) store their payload as JSON in `key`. +- Folders: `{id, name, parentId, ownerType, ownerId, customIcon, customColor, createdAt, updatedAt}`, all plaintext, tree via `parentId`. + +## Cryptography (must match the web app byte for byte) + +Key hierarchy: + +``` +master password --PBKDF2-SHA256, 600000 iterations, 16-byte salt--> AES-256-GCM unlock key +unlock key decrypts suite.privateKey envelope --> RSA-4096 PKCS#8 PEM +PEM --importKey(pkcs8, RSA-OAEP SHA-256, extractable: false, ['decrypt'])--> private CryptoKey +suite.certificate --extract SPKI from X.509 DER--> RSA-OAEP public CryptoKey (['encrypt']) +``` + +Private-key envelope (base64 of): + +``` +[4 bytes version, big-endian uint32, = 1][16 bytes PBKDF2 salt][12 bytes AES-GCM IV][ciphertext || 16-byte GCM tag] +``` + +Field ciphertext (base64 of): + +``` +[4 bytes chunk count, big-endian uint32][512-byte RSA-OAEP block] * count +``` + +- Plaintext is UTF-8, chunked at 446 bytes. Decrypt all chunks, concatenate the bytes, then decode UTF-8 once. Decoding per chunk tears multi-byte characters. +- An empty string encrypts as one chunk of zero bytes. +- The salt lives inside the envelope, so the KDF parameters needed for unlock are always available offline once the suite row is cached. +- Argon2id (64 MiB, 3 iterations, parallelism 1, 32-byte output, 16-byte salt) is used only for password-protected sends and link shares. The web app uses `argon2-browser` (WASM). + +## Ephemeral send payload + +``` +POST /api/v1/sends +{ "encryptedPayload": "", "payloadType": "text" | "credential", + "maxViews": 1..cap, "hasPassword": false, "wrappedKey": null, "argon2idSalt": null } +``` + +- The payload is AES-256-GCM encrypted client-side under a random content key. +- Without a password the content key travels in the URL fragment of the share link and never reaches the server. +- With a password, `wrappedKey` is the content key wrapped under an Argon2id-derived key and `argon2idSalt` is its salt. +- Optional expiry is `ttlSeconds` (0 or absent means none, cap 2592000 = 30 days), stored as `expiresAt`. +- `maxViews` cap is 100 (server constant, not exposed by any endpoint). Unlimited views are refused. Five failed password attempts burn the send. +- Row JSON: `id, token, payloadType, hasPassword, maxViews, viewCount, remainingViews, expiresAt, createdAt`. +- Content blob layout is base64 of `[12-byte IV][ciphertext || tag]`; `wrappedKey` uses the same layout with the raw content key as plaintext; `argon2idSalt` is base64 of 16 random bytes. +- Recipient link: `/index.php/apps/keepiq/public/send/#k=` when no password; without the fragment when password-protected. +- The web app's "credential" payload type is free text, not structured JSON. `GET /api/v1/sends` lists the caller's sends; `DELETE` revokes. +- The recipient link points at the Keepiq web app's public page. The extension does not render it. + +## Server-side settings that affect the client + +- Admin `default_session_timeout` is one of `session`, `10min`, `30min` and is only a suggested default. The lock is client-enforced; the server has no session to kill. +- Admin `offline_cache_enabled` gates the manifest endpoint with a 403. Per-user `offline_cache_optin` exists too. When the manifest is unavailable the client falls back to paginated listing. +- Admin `min_password_length` and `min_password_score` apply to master password changes, which the extension does not perform. diff --git a/openspec/references/keepiq-extension/architecture/adr-004-popup-ui-react.md b/openspec/references/keepiq-extension/architecture/adr-004-popup-ui-react.md new file mode 100644 index 000000000..964e8cf9b --- /dev/null +++ b/openspec/references/keepiq-extension/architecture/adr-004-popup-ui-react.md @@ -0,0 +1,30 @@ +# ADR-004: The popup UI is built with React + +**Status**: accepted + +**Date**: 2026-09-22 + +## Context + +The popup has many repeated, stateful pieces: the item card in the vault list, filter chips, the account switcher rows, settings sections, form rows for additional fields. The WXT template ships a vanilla TypeScript popup, and the first spec drafts kept that with one module per view. That works for a handful of screens but makes shared components and their state hard to keep consistent as the chain grows. + +## Decision + +The popup (and any other extension page, such as the pop-out window or a future options page) is written in React with TypeScript, wired through WXT's `@wxt-dev/module-react`. Background and content scripts stay plain TypeScript; React never runs there. + +Rules that follow: + +- **The background still owns all state.** React components read state and trigger actions only through the typed runtime messages in `src/messages.ts`, via a small set of hooks under `entrypoints/popup/hooks/` (for example `useVaultState`, `useSettings`, `useMessage`). No component calls `browser.storage` or the API client directly. +- **Components live under `entrypoints/popup/components/`**, one file per component, `.tsx`. Screen-level compositions live under `entrypoints/popup/views/`. Both are React function components; no class components. +- **Local UI state stays in React** (`useState`, `useReducer`, context). No global state library is added until a change shows a need and records it here. +- **Decrypted values are React state only.** They are requested through `item.decrypt` for what is on screen and dropped on unmount. Nothing decrypted is written to storage from the popup. +- **Styling** stays plain CSS with the existing token approach in `entrypoints/popup/popup.css`, split per component when a file grows. No CSS-in-JS runtime. +- **Testing** of pure logic stays in `src/` with vitest. Component tests, when added, use React Testing Library; a change that adds them says so in its tasks. +- **Dependencies**: `react`, `react-dom`, `@types/react`, `@types/react-dom`, `@wxt-dev/module-react`, plus `eslint-plugin-react-hooks`. Added once by `ext-accounts-and-unlock`. + +## Consequences + +- One component for the item card, one for the filter chip, one for a masked field with reveal and copy, reused by list, detail, forms, send and settings. +- Bundle grows by roughly 45 KB gzipped for React and ReactDOM. Acceptable for a popup that loads from disk. +- The template's `entrypoints/popup/main.ts` becomes `main.tsx` mounting ``; the on/off scaffold is removed as ext-accounts-and-unlock already planned. +- Builders must keep the boundary: React in extension pages only, messages as the only bridge to the background. A component importing from `src/api/` or `src/crypto/` is a review blocker. diff --git a/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/.openspec.yaml b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/.openspec.yaml new file mode 100644 index 000000000..1b9acb7fd --- /dev/null +++ b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 diff --git a/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/design.md b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/design.md new file mode 100644 index 000000000..14a1cfeff --- /dev/null +++ b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/design.md @@ -0,0 +1,145 @@ +## Context + +The repo is the WXT starter: a background that owns one flag, a stateless popup, a content script handshake and typed message unions in `src/messages.ts`. This change replaces the flag with the real foundation: accounts, the Keepiq API client, the crypto module and the unlock/lock state machine. Every later change in the chain consumes these modules and adds messages to the same unions. + +Constraints that shape the design: the background is the only place with host permissions and the only owner of state (WXT-AND-BROWSERS.md); Chrome runs an MV3 service worker that sleeps after roughly 30 seconds, Firefox runs a persistent MV2 page; key material may live in `storage.session` or memory only (ADR-002); the server has no session, so the lock is entirely client-side (ADR-003). + +## Goals / Non-Goals + +**Goals:** +- One account store, one API client, one key store and one lock engine, each in its own module and reachable from the popup only through typed messages. +- Byte-compatible crypto with the Keepiq web app (ADR-003) so downstream changes decrypt and encrypt without re-deriving formats. +- Bitwarden's login, unlock, account switcher and vault timeout behaviour (ADR-001) on Keepiq's two-credential model. +- Both browser targets build and load with no `chrome.*` in executable code. + +**Non-Goals:** +- Fetching, caching or rendering secrets, folders or types (ext-vault-browse owns `vaultCache.`). +- The settings screen, the timeout option picker UI and PIN unlock (ext-settings). +- Suite rotation, revocation or compromise recovery (needs a signed vault-key proof, ADR-003; the extension links to the web app). +- Autofill, the badge and any content-script behaviour beyond the existing `page_ready` handshake. + +## Decisions + +- **Background owns all account and key state; the popup is a renderer.** The popup sends a message, the background mutates storage and returns a full `PopupState`. Alternative: popup reads `storage.local` directly, rejected because two writers race and the key must never be readable from the popup. +- **Account status is derived, not stored.** `appPassword === null` gives "Logged out", a key present in the key store gives "Unlocked", otherwise "Locked". Alternative: a stored `status` field, rejected because it can drift from the truth after a worker restart. +- **Manual "Log out" removes the account; a 401 or the timeout action "Log out" keeps the identity record.** This matches Bitwarden's switcher and lets the user re-enter only the app password after a revocation. Alternative: one behaviour for both, rejected because a revoked app password is the common case and retyping the server URL is friction. +- **The popup calls `browser.permissions.request` itself, inside the click handler.** Optional permissions need a user gesture, which the background never has. The background then verifies and stores. Alternative: `host_permissions: ['']`, rejected for store review reasons (CLAUDE.md open decision). +- **Verification order is identity first, suites second.** `GET /ocs/v2.php/cloud/user` (`OCS\Provisioning`, Nextcloud core) separates "not Nextcloud" and "bad app password" from Keepiq errors; `GET /api/v1/suites` (`EncryptionSuiteController::index`) separates "Keepiq missing" (404 without a JSON `message`) from "no active suite" (empty or no `active` row). +- **The suite row is cached by this change under `suite.`, separate from `vaultCache.`.** Unlock needs it offline and ext-vault-browse does not exist yet. Alternative: wait for the manifest cache, rejected because unlock would then need the network. +- **Key store is a shim with two backends.** `src/vault/key-store.ts` writes PKCS#8 bytes to `browser.storage.session` when it exists and to a module-level `Map` otherwise (Firefox below 115, persistent MV2 page). It re-imports to a non-extractable `CryptoKey` on first use per worker generation and memoises it. Alternative: raise `strict_min_version` to 115, deferred to a later decision. +- **Timeout engine uses one `browser.alarms` alarm plus a popup port.** A `vault-timeout` alarm with `periodInMinutes: 1` runs while any account is unlocked and compares `lastInteractionAt` against each account's timeout. The popup opens a `runtime.connect` port on load; `onDisconnect` implements "Immediately". `browser.idle.onStateChanged` with state `locked` implements "On system lock". `vault.status` runs the same check first so a sleeping worker cannot extend a session. Alternative: `setTimeout` in the worker, rejected because MV3 kills it. +- **Timeout policy is one constant object** (`TIMEOUT_POLICY = { maxMinutes, forcedAction }` in `src/vault/timeout.ts`) so an admin clamp later touches one place (ADR-002). +- **"Never" writes the same PKCS#8 base64 to `storage.local` under `neverLockKey.`** and the key store reads it back after a restart. Every lock path deletes it. Alternative: refuse "Never", rejected for Bitwarden parity. +- **Errors cross the message boundary as a discriminated `code`, not as thrown errors.** Every request/response message resolves to `{ ok: true, state } | { ok: false, code, message }` so the popup can map codes to copy and `sendMessage` never rejects on a domain error. +- **The popup is React with TypeScript through `@wxt-dev/module-react` (ADR-004).** `main.tsx` mounts ``, which picks a view from `state.screen`; views compose components, and hooks are the only bridge to the background (`useMessage` wraps `browser.runtime.sendMessage` with the typed envelopes, `usePopupState` holds the background-owned `PopupState`). No component imports `src/api` or `src/crypto`, and the background and content scripts stay plain TypeScript. This change lands the dependencies once for the whole chain. Alternative: the template's vanilla view modules, rejected by ADR-004 because shared stateful components across seven changes need one component model. +- **Scaffold toggle removed.** `get_state`, `set_enabled`, `enabled_changed` and the badge counter go; `page_ready` stays for ext-autofill. Keeping dead UI in the real popup costs more than the scaffold is worth. +- **Crypto tests use vitest, the one automated test in the repo.** Envelope layout, chunk framing and round trips are cheap to test and expensive to debug against a live server. + +## Module layout + +New: + +- `src/api/types.ts`: suite, secret (normal and blocked), folder, type, paginated envelope, manifest, user settings, OCS user. +- `src/api/client.ts`: `createClient(account)`, `request()`, error classes `ApiError`, `SessionRevoked`, `VaultWriteLocked`, `Offline`, `KeepiqNotInstalled`, identity helpers `fetchIdentity`, `fetchAvatarDataUrl`, and an `onUnauthorized` hook the account store registers. +- `src/crypto/base64.ts`, `src/crypto/envelope.ts` (decode and version check), `src/crypto/kdf.ts` (PBKDF2 to AES-GCM key), `src/crypto/rsa.ts` (PKCS#8 import, X.509 SPKI extraction, chunked RSA-OAEP decrypt and encrypt), `src/crypto/index.ts`. +- `src/crypto/*.test.ts`: vitest round-trip and layout tests. +- `src/accounts/normalize-origin.ts`: URL to origin, https rule with the dev-host allow list. +- `src/accounts/store.ts`: `storage.local` schema, add, reauthenticate, remove, removeAll, setActive, purge helpers, the 5-account limit and duplicate check. +- `src/accounts/verify.ts`: identity then suites, error mapping, avatar fetch. +- `src/vault/key-store.ts`: session or memory backend, re-import, `neverLockKey` handling. +- `src/vault/unlock.ts`: `unlock(accountId, method)`, suite fetch when uncached, epoch check, `lock`, `lockAll`, `logoutForTimeout`. +- `src/vault/timeout.ts`: settings defaults, `TIMEOUT_POLICY`, alarm, idle listener, popup port tracking. +- `entrypoints/popup/main.tsx`: mounts `` and opens the `popup` interaction port. +- `entrypoints/popup/App.tsx`: reads `usePopupState()` and renders the view for `state.screen` inside the `Header`. +- `entrypoints/popup/hooks/useMessage.ts`: typed wrapper around `browser.runtime.sendMessage` for `PopupToBackground`, resolving to `Result`; the single `unknown` cast in the popup. +- `entrypoints/popup/hooks/usePopupState.ts`: fetches `vault.status` on mount, exposes `state`, `refresh()` and `dispatch(message)` that replaces the state with the returned one. +- `entrypoints/popup/components/Header.tsx` (title, avatar slot opening the switcher), `Button.tsx`, `TextField.tsx` (label, error, show/hide toggle for `type="password"`), `ErrorBanner.tsx`, `Avatar.tsx` (data URL or initials disc). +- `entrypoints/popup/views/AddAccount.tsx`, `LogInAgain.tsx`, `Unlock.tsx`, `AccountSwitcher.tsx`, `Unlocked.tsx` (placeholder with identity and Lock, replaced by ext-vault-browse). + +Edited: + +- `wxt.config.ts`: `modules: ['@wxt-dev/module-react']`; `permissions` add `alarms`, `idle`; `optional_host_permissions` (Chrome) or `optional_permissions` (Firefox) for `https://*/*` and `http://*/*`; `strict_min_version` stays `109.0`. +- `eslint.config.mjs`: `eslint-plugin-react-hooks` recommended rules for `entrypoints/**/*.tsx`. +- `src/messages.ts`: new unions and state types below; scaffold messages removed. +- `entrypoints/background.ts`: message router, alarm and idle listeners, port listener, `onUnauthorized` wiring. +- `entrypoints/popup/index.html` (a single `#root` and the `main.tsx` script), `popup.css` (styles for form fields, buttons, avatar disc, list rows, switcher panel and error text, same token approach). `main.ts` is deleted in favour of `main.tsx`. +- `package.json`: `react`, `react-dom` dependencies; `@types/react`, `@types/react-dom`, `@wxt-dev/module-react`, `eslint-plugin-react-hooks`, `vitest` dev dependencies; a `test` script. + +## Message contract + +```ts +export type AccountStatus = 'unlocked' | 'locked' | 'logged_out' + +export interface AccountSummary { + id: string + origin: string + host: string + uid: string + displayName: string + avatarDataUrl: string | null + status: AccountStatus + active: boolean +} + +export type PopupScreen = 'add_account' | 'reauthenticate' | 'unlock' | 'unlocked' + +export interface PopupState { + screen: PopupScreen + accounts: AccountSummary[] + active: AccountSummary | null + /** One-shot banner, e.g. "Session revoked, please log in again". */ + notice: string | null + canAddAccount: boolean +} + +export type ErrorCode = + | 'insecure_url' | 'invalid_url' | 'permission_denied' | 'unreachable' | 'not_nextcloud' + | 'unauthorized' | 'keepiq_missing' | 'no_active_suite' | 'duplicate' | 'limit_reached' + | 'invalid_master_password' | 'offline_no_cache' | 'session_revoked' | 'write_locked' | 'unknown' + +export type Result = { ok: true; state: PopupState } | { ok: false; code: ErrorCode; message: string } + +/** Popup → background. Request/response, every arm resolves to `Result`. */ +export type PopupToBackground = + | { kind: 'accounts.list' } + | { kind: 'accounts.add'; serverUrl: string; username: string; appPassword: string } + | { kind: 'accounts.reauthenticate'; accountId: string; appPassword: string } + | { kind: 'accounts.remove'; accountId: string } + | { kind: 'accounts.removeAll' } + | { kind: 'accounts.switch'; accountId: string } + | { kind: 'vault.unlock'; accountId: string; method: { type: 'masterPassword'; masterPassword: string } } + | { kind: 'vault.lock'; accountId: string } + | { kind: 'vault.lockAll' } + | { kind: 'vault.status' } +``` + +`accounts.list` resolves to the same `Result` so the switcher can refresh without a second type. The popup port is named `'popup'`; it carries no payload, only connect and disconnect. + +Storage keys in `storage.local`: `accounts` (record by id), `activeAccountId`, `settings.`, `suite.`, `vaultCache.` (ext-vault-browse), `neverLockKey.`. In `storage.session`: `privateKeyPkcs8.`, `unlockedAt.`, `lastInteractionAt`. + +## Browser differences + +- `storage.session` is absent on Firefox below 115: `key-store.ts` falls back to memory. Both branches expose the same async API. +- Optional host permissions: `optional_host_permissions` on Chrome MV3, `optional_permissions` with origin patterns on Firefox MV2. `wxt.config.ts` branches on the `browser` argument. `browser.permissions.request({ origins })` is identical at runtime. +- `browser.alarms` and `browser.idle` exist on both. Chrome MV3 enforces a 30 second minimum period; the engine uses 1 minute. +- The MV3 worker sleeps: every listener re-reads state from storage and the `CryptoKey` memo is rebuilt from bytes. Nothing relies on module state surviving between events except the Firefox memory backend, which runs on a persistent page. +- `browser.action` vs `browser.browserAction`: already shimmed in `src/browser-action.ts`; this change does not touch the badge. + +## Risks / Trade-offs + +- [Raw PKCS#8 bytes sit in `storage.session`] → Accepted per ADR-002; `setAccessLevel` is left at its default so content scripts cannot read it, and every lock path deletes the key. +- [`permissions.request` from the popup closes the popup on some Chrome versions] → The form values are React state in `AddAccount`; they are mirrored to the popup's `sessionStorage` on change so a reopened popup restores them for the retry. +- [PBKDF2 at 600 000 iterations takes about half a second] → Unlock button shows a busy state; short default timeouts are avoided (ADR-002). +- [A 1 minute alarm can leave a vault unlocked up to 59 seconds past its timeout] → `vault.status` re-checks on every popup open, so the popup never renders an expired session. +- [Nextcloud returns HTML for unknown routes] → The client treats a 404 without a JSON `message` on a Keepiq route as `KeepiqNotInstalled` and everything else as `ApiError`. +- [App password in `storage.local` is readable by anyone with profile access] → Accepted per ADR-002; revoking the app password in Nextcloud is the remote kill switch and 401 purges everything. +- [Two accounts on the same origin share one host permission] → Removal never revokes the permission, so removing one account cannot break the other. +- [`crypto.subtle` is missing on `http` origins in some contexts] → The extension pages are `chrome-extension://` and `moz-extension://`, which are secure contexts; only the server URL may be `http` for dev hosts. + +## Open Questions + +- Whether the `http` allow list (`localhost`, `127.0.0.1`, `*.test`, `*.local`) should be a build-time setting instead of a code constant. +- Whether "On system lock" stays in the option list; Bitwarden offers it and `browser.idle` makes it cheap, but ADR-002's list omits it. +- Whether a "Logged out" account should expire and be removed automatically after some time, as Bitwarden does not. +- Whether to raise `strict_min_version` to 115 and drop the memory backend once Firefox usage data exists. +- The content script `matches` allow list (CLAUDE.md open decision) is untouched here. diff --git a/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/proposal.md b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/proposal.md new file mode 100644 index 000000000..99d395c0e --- /dev/null +++ b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/proposal.md @@ -0,0 +1,66 @@ +--- +kind: code +depends_on: [] +chain: + - ext-accounts-and-unlock + - ext-vault-browse + - ext-vault-edit + - ext-generator + - ext-send + - ext-settings + - ext-autofill +--- + +## Why + +The extension is still the WXT starter scaffold: it has no notion of a Keepiq account, cannot talk to a server and cannot decrypt anything. +Every later change in the chain (vault browsing, editing, generator, send, settings, autofill) needs an authenticated account, an unlocked private key and an API client, so those have to exist first and be owned by exactly one module. + +This is change 1 of 7 in the chain; it mirrors Bitwarden's "Log in", "Unlock", "Account switcher" and "Vault timeout" surfaces (ADR-001) on top of Keepiq's app-password plus master-password model (ADR-002, ADR-003). + +## What Changes + +- Add account: a first-run screen that takes a Nextcloud server URL, username and app password, verifies them against Nextcloud and Keepiq, requests the server origin as an optional host permission and stores the account. +- Account switcher: a Nextcloud avatar in the popup header that opens a panel listing up to 5 accounts with Unlocked / Locked / Logged out status, per-account Lock and Log out, Lock all, Log out all and Add account. +- Unlock and lock: an unlock screen that derives the unlock key from the master password and decrypts the suite's private-key envelope client-side, a lock state machine with Bitwarden's timeout options and actions, and a private key that lives in `storage.session` only (ADR-002). +- Keepiq API client: one background-only module that applies Basic auth and the OCS header, treats 401 as revocation, 423 as a retryable write lock and network failure as offline, with typed response shapes from ADR-003. +- Crypto module: envelope decode, PBKDF2, PKCS#8 import, X.509 SPKI extraction and chunked RSA-OAEP, byte-compatible with the Keepiq web app (ADR-003). +- Manifest: add `alarms` and `idle` permissions and optional host permissions for `https://*/*` and `http://*/*` (Chrome `optional_host_permissions`, Firefox MV2 `optional_permissions`). +- **BREAKING** for the scaffold only: the popup's on/off toggle, its badge counter and the `get_state`, `set_enabled` and `enabled_changed` messages are removed. The content script's `page_ready` handshake stays for ext-autofill. + +## Capabilities + +### New Capabilities +- `account-management`: adding, verifying, switching, locking out and removing Nextcloud accounts, with the avatar-driven account switcher. +- `vault-unlock`: unlocking a suite with the master password, private key lifetime, manual and timed lock, timeout actions. +- `api-client`: the single HTTP client for Keepiq and Nextcloud identity routes, its auth headers, error mapping and typed responses. + +### Modified Capabilities + +None. `openspec/specs/` is empty today. + +## Deviations from Bitwarden + +- Login asks for Server URL, Username and App password instead of email and master password. Forced: Keepiq has no login endpoint, and a Nextcloud app password is the only credential an extension can hold (ADR-003). +- An account has two credentials, an app password for the server and a master password for the vault, so "Logged out" and "Locked" are distinct states with distinct screens. Forced by the same API model (ADR-002). +- The account switcher and the unlock screen identify an account by Nextcloud display name and server host, and the header shows the Nextcloud profile avatar instead of Bitwarden's initials disc. Not forced: Nextcloud supplies both and email may be empty on a Nextcloud account. Initials are the fallback when the avatar cannot be fetched. +- Verification on add contacts Nextcloud and Keepiq before anything is stored and distinguishes "Keepiq not installed" and "no active suite" errors. Not forced, but Bitwarden has one server and Keepiq is an optional app on an arbitrary Nextcloud. +- "Never" as a vault timeout shows a warning and is the only case that writes key material to `storage.local`. Same as current Bitwarden; recorded because ADR-002 calls it out. +- No "Log in with device", SSO, biometric unlock or "Remember email" options. Out of scope, not deviations (ADR-001). + +## Keepiq API used + +- `GET /ocs/v2.php/cloud/user` (Nextcloud identity, ADR-003) +- `GET /index.php/avatar/{uid}/{size}` (Nextcloud avatar, ADR-003) +- `GET /api/v1/suites` + +All other typed shapes in the API client (`/api/v1/secrets`, `/api/v1/folders`, `/api/v1/secret-types`, `/api/v1/offline/manifest`, `/api/settings/user`) are declared here for downstream changes but not called by this change. + +## Impact + +- New: `src/api/`, `src/crypto/`, `src/accounts/`, `src/vault/`, `entrypoints/popup/App.tsx`, `entrypoints/popup/views/`, `entrypoints/popup/components/`, `entrypoints/popup/hooks/`. +- Edited: `wxt.config.ts` (permissions, React module), `eslint.config.mjs` (react-hooks rules), `src/messages.ts` (message unions), `entrypoints/background.ts` (message router, timeout engine), `entrypoints/popup/index.html`, `popup.css`. `entrypoints/popup/main.ts` becomes `main.tsx`. +- Unchanged: `entrypoints/content.ts`, `src/browser-action.ts`; the background stays plain TypeScript. +- New dependencies (ADR-004, added once here for the whole chain): `react`, `react-dom`, `@types/react`, `@types/react-dom`, `@wxt-dev/module-react`, `eslint-plugin-react-hooks`. New dev dependency `vitest` for the crypto module only. +- Store review: optional host permissions for all origins are requested at runtime per server, which both stores accept more readily than a static `` host permission. The content script `matches` question stays open (CLAUDE.md). +- Downstream: ext-vault-browse owns the `vaultCache.` key named here and replaces the unlocked placeholder view. ext-settings owns the timeout option picker and PIN unlock; this change ships the engine and defaults they configure. diff --git a/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/account-management/spec.md b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/account-management/spec.md new file mode 100644 index 000000000..6e9256f56 --- /dev/null +++ b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/account-management/spec.md @@ -0,0 +1,155 @@ +## ADDED Requirements + +### Requirement: First-run add account screen +The popup SHALL show an "Add account" screen when no account is stored, with three fields: Server URL, Username and App password. Help text MUST tell the user to create a dedicated app password under Nextcloud Settings, Security, and never to enter the Nextcloud login password. Once a valid server URL is typed the help text MUST link to `/index.php/settings/user/security` on that server. Nothing is stored until verification succeeds. + +#### Scenario: Fresh profile +- **GIVEN** `storage.local` has no `accounts` entry +- **WHEN** the user opens the popup +- **THEN** the "Add account" screen is shown with all three fields empty and the "Add account" button disabled until every field has a value + +#### Scenario: Security settings link follows the server URL +- **GIVEN** the user typed `cloud.example.org` in Server URL +- **WHEN** the field loses focus +- **THEN** the help text links to `https://cloud.example.org/index.php/settings/user/security` and opens it in a new tab + +### Requirement: Server URL normalisation +The extension SHALL accept a bare host, a full origin, or any URL under `/index.php/apps/keepiq` as the Server URL, and MUST store only the origin (`scheme://host[:port]`). A bare host defaults to `https`. `http` MUST be rejected unless the host is `localhost`, `127.0.0.1`, or ends in `.test` or `.local`. + +#### Scenario: Keepiq web app URL pasted +- **WHEN** the user enters `https://cloud.example.org/index.php/apps/keepiq/vault` +- **THEN** the account is verified and stored with origin `https://cloud.example.org` + +#### Scenario: Plain http on a public host +- **WHEN** the user enters `http://cloud.example.org` +- **THEN** the screen shows "Use https for this server" and no request is sent + +#### Scenario: Plain http on a dev host +- **WHEN** the user enters `http://stable35.test:8080` +- **THEN** the origin `http://stable35.test:8080` is accepted + +### Requirement: Host permission requested at add time +The extension SHALL request `/*` as an optional host permission from the popup, inside the click handler of "Add account", before verifying the credentials. If the user declines, the extension MUST show "Keepiq needs permission to reach " and MUST NOT store anything or send any request. + +#### Scenario: Permission granted +- **WHEN** the user clicks "Add account" and accepts the browser's permission prompt +- **THEN** verification starts against that origin + +#### Scenario: Permission declined +- **WHEN** the user rejects the permission prompt +- **THEN** the form keeps its values, the error is shown and `storage.local` is unchanged + +### Requirement: Credentials verified before storing +The extension SHALL verify an account by calling `GET /ocs/v2.php/cloud/user` and then `GET /api/v1/suites` (ADR-003) with the entered credentials, from the background. Only when both succeed and an active suite exists MUST the account be stored. The uid and display name come from the identity response, not from the typed username. + +#### Scenario: Valid account +- **GIVEN** the credentials belong to a Nextcloud user with Keepiq installed and one `active` suite +- **WHEN** verification runs +- **THEN** the account is stored, becomes the active account and the popup shows the unlock screen for it + +### Requirement: Distinct verification errors +The extension SHALL show a distinct message for each verification failure and MUST keep the form values so the user can correct one field. Failures: host unreachable ("Could not reach "), identity route not answering with the OCS JSON envelope (" does not look like a Nextcloud server"), 401 ("Wrong username or app password"), 404 on the suites route ("Keepiq is not installed on "), and no suite with `status === 'active'` ("Open the Keepiq web app once and set a master password, then try again"). + +#### Scenario: Bad app password +- **WHEN** the identity call returns 401 +- **THEN** "Wrong username or app password" is shown and the App password field is cleared + +#### Scenario: Nextcloud without Keepiq +- **WHEN** the identity call succeeds and the suites call returns 404 +- **THEN** "Keepiq is not installed on " is shown + +#### Scenario: Keepiq without a suite +- **WHEN** the suites call returns an array with no `active` row +- **THEN** the message tells the user to open the Keepiq web app and set a master password + +#### Scenario: Server offline +- **WHEN** the identity request fails at the network level +- **THEN** "Could not reach " is shown + +### Requirement: Account record storage +The extension SHALL store each account in `storage.local` under `accounts[]` with `origin`, `uid`, `displayName`, `email`, `avatarDataUrl` and `appPassword`, plus `activeAccountId` and a per-account `settings.` entry. The app password is cleared by logout, account removal and a 401 response (ADR-002). The record and settings are cleared by account removal only. + +#### Scenario: Account added +- **WHEN** verification succeeds +- **THEN** `accounts[]` holds the six fields, `activeAccountId` is `` and `settings.` holds the default timeout settings + +### Requirement: Account limit and duplicates +The extension SHALL allow at most 5 accounts, as in Bitwarden, and MUST reject a second account with the same origin and uid. + +#### Scenario: Sixth account +- **GIVEN** 5 accounts are stored +- **WHEN** the user opens the account switcher +- **THEN** "Add account" is disabled with the hint "Maximum of 5 accounts reached" + +#### Scenario: Same user on the same server +- **GIVEN** an account for `https://cloud.example.org` with uid `alice` exists +- **WHEN** verification of a new account resolves to the same origin and uid +- **THEN** "This account is already added" is shown and no second record is created + +### Requirement: Avatar fetched and cached +The extension SHALL fetch `GET /index.php/avatar/{uid}/64` with the account's credentials from the background after a successful add, store it as a data URL in `accounts[].avatarDataUrl` in `storage.local`, and render it in the popup's top-right corner for the active account. When the fetch fails the extension MUST render the display name's initials instead and retry on the next successful login. The cached avatar is cleared with the account record. + +#### Scenario: Avatar available +- **WHEN** the avatar request returns an image +- **THEN** the header shows it as a 32 px circle and `avatarDataUrl` is set + +#### Scenario: Avatar unavailable +- **WHEN** the avatar request returns a non-2xx status or fails +- **THEN** the header shows the initials disc and `avatarDataUrl` is `null` + +### Requirement: Account switcher panel +Clicking the header avatar SHALL open a panel listing every stored account with avatar or initials, display name, server host and one status label: "Unlocked", "Locked" or "Logged out". The panel MUST offer per-account "Lock" (only when Unlocked) and "Log out", plus "Add account", "Lock all" and "Log out all". The active account MUST be marked. + +#### Scenario: Two accounts, one unlocked +- **GIVEN** account A is unlocked and active, account B is locked +- **WHEN** the user opens the switcher +- **THEN** A shows "Unlocked" with a "Lock" action and is marked active, B shows "Locked" without a "Lock" action, and both show "Log out" + +#### Scenario: Account whose app password was revoked +- **GIVEN** account B received a 401 earlier +- **WHEN** the user opens the switcher +- **THEN** B shows "Logged out" + +### Requirement: Exactly one active account +The extension SHALL keep exactly one active account in `storage.local` `activeAccountId`. Selecting another account in the switcher MUST change the active account and re-render the popup for it. Switching MUST NOT lock or log out the previous account. + +#### Scenario: Switch to a locked account +- **GIVEN** A is unlocked and active, B is locked +- **WHEN** the user selects B +- **THEN** the popup shows the unlock screen for B, and A stays unlocked in the switcher + +### Requirement: Log out removes the account +"Log out" on an account SHALL purge its app password, private key, cached suite row, `vaultCache.`, `settings.` and its `accounts[]` record (ADR-002). Logging out the active account MUST make the next remaining account active, or show the "Add account" screen when none is left. The browser host permission for the origin is kept. + +#### Scenario: Log out the only account +- **WHEN** the user clicks "Log out" on the only account +- **THEN** `storage.local` has no key for that account and the popup shows the "Add account" screen + +#### Scenario: Log out the active account among several +- **GIVEN** A is active and B exists +- **WHEN** the user logs out A +- **THEN** B is active and the popup renders B's current state + +### Requirement: Lock all and Log out all +"Lock all" SHALL lock every unlocked account without changing the active account. "Log out all" SHALL ask for confirmation and then remove every account, clearing `accounts` and `activeAccountId`. + +#### Scenario: Lock all +- **GIVEN** A and B are unlocked +- **WHEN** the user clicks "Lock all" +- **THEN** both show "Locked" and the popup shows the unlock screen for the active account + +#### Scenario: Log out all confirmed +- **WHEN** the user clicks "Log out all" and confirms +- **THEN** the "Add account" screen is shown and no account data remains in `storage.local` or `storage.session` + +### Requirement: Re-login after revocation +When an account is in the "Logged out" state (app password cleared by a 401 or by the timeout action "Log out") the extension SHALL keep its identity record and settings and, when it is the active account, show a "Log in again" screen with the account's display name, server host and a single App password field. Submitting MUST run the same verification as adding, without the host permission prompt, and MUST NOT create a second record. + +#### Scenario: Session revoked +- **GIVEN** the active account's app password was revoked in Nextcloud +- **WHEN** any request returns 401 +- **THEN** the popup shows "Session revoked, please log in again" above the App password field + +#### Scenario: New app password accepted +- **WHEN** the user enters a valid app password on the "Log in again" screen +- **THEN** the account is stored with the new app password, status becomes "Locked" and the unlock screen is shown diff --git a/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/api-client/spec.md b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/api-client/spec.md new file mode 100644 index 000000000..c48bdf8f0 --- /dev/null +++ b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/api-client/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Single background-only client +All HTTP requests to a Nextcloud or Keepiq server SHALL go through `src/api/client.ts`, bound to one account, and MUST run in the background only. The popup and content scripts never hold an app password after submitting the add or re-login form and never call `fetch` against a server. + +#### Scenario: Popup needs server data +- **WHEN** a popup view needs a server response +- **THEN** it sends a typed message to the background and the background performs the request + +### Requirement: Request shape +Every request SHALL use the account's origin plus `/index.php/apps/keepiq` as base URL for Keepiq routes, an `Authorization: Basic` header built from the account's uid and app password, `OCS-APIRequest: true`, `Accept: application/json`, `credentials: 'omit'`, and `Content-Type: application/json` with a JSON body on writes (ADR-003). Nextcloud identity routes (`/ocs/v2.php/cloud/user`, `/index.php/avatar/{uid}/{size}`) use the origin as base URL with the same headers. + +#### Scenario: Suites request +- **WHEN** the client fetches the suites for `https://cloud.example.org` +- **THEN** the URL is `https://cloud.example.org/index.php/apps/keepiq/api/v1/suites` and the request carries the four headers and no cookies + +#### Scenario: OCS identity request +- **WHEN** the client fetches the identity +- **THEN** it requests `/ocs/v2.php/cloud/user?format=json` and returns the unwrapped `ocs.data` object + +### Requirement: 401 is revocation +A 401 response SHALL purge the account's private key, cached suite row, `vaultCache.` and app password, mark the account "Logged out" and reject the call with a `SessionRevoked` error whose message is "Session revoked, please log in again" (ADR-002). The identity and settings records stay. + +#### Scenario: App password revoked in Nextcloud +- **GIVEN** the account is unlocked +- **WHEN** any request returns 401 +- **THEN** `storage.session` has no key for the account, `accounts[].appPassword` is `null` and the popup shows the "Log in again" screen + +#### Scenario: 401 during add verification +- **GIVEN** no account record exists yet for the credentials +- **WHEN** verification returns 401 +- **THEN** the client reports the error without touching storage + +### Requirement: 423 is a retryable write lock +A 423 response SHALL be reported as a `VaultWriteLocked` error with the server's `message` and MUST NOT be treated as an authentication failure, MUST NOT purge anything and MUST NOT change the account state (ADR-003). + +#### Scenario: Write during key migration +- **WHEN** a write returns 423 +- **THEN** the caller receives `VaultWriteLocked` and the account stays "Unlocked" + +### Requirement: Network failure is offline +When `fetch` rejects or the response has no HTTP status the client SHALL reject with an `Offline` error. Cached data in `storage.local` and an unlocked key in `storage.session` MUST remain usable. + +#### Scenario: Server unreachable while unlocked +- **GIVEN** the account is unlocked with a cached suite row +- **WHEN** a request fails at the network level +- **THEN** the caller receives `Offline` and the account stays "Unlocked" + +### Requirement: Other error responses +For any other non-2xx status the client SHALL reject with an `ApiError` carrying the status and the `message` field from the `{"message": ""}` body (ADR-003). When the body is not that JSON shape the message MUST be the HTTP status text, and a 404 with a non-JSON body on a Keepiq route MUST be exposed as `KeepiqNotInstalled`. + +#### Scenario: Validation error +- **WHEN** the server returns 400 with `{"message": "name is required"}` +- **THEN** the caller receives `ApiError` with status 400 and that message + +#### Scenario: Keepiq app missing +- **WHEN** `GET /api/v1/suites` returns 404 with an HTML body +- **THEN** the caller receives `KeepiqNotInstalled` + +### Requirement: Typed response shapes +The client SHALL export TypeScript types for the shapes in ADR-003: suite rows, secret rows in both the normal and the `blocked: true` shape, folder rows, secret-type rows, the paginated `{items, total, page, limit}` envelope, the offline manifest, and user settings. Payloads MUST be typed `unknown` at the fetch boundary and narrowed once, in the client. + +#### Scenario: Blocked secret row +- **WHEN** a secret row has `blocked: true` +- **THEN** the type exposes `blockedReason` and no `key`, `login` or `additionalFields` + +### Requirement: Logged-out account never hits the network +A client bound to an account whose app password is `null` SHALL reject every call with `SessionRevoked` without sending a request. + +#### Scenario: Background job on a logged-out account +- **GIVEN** the account is "Logged out" +- **WHEN** any module requests data through its client +- **THEN** no request is sent and `SessionRevoked` is returned diff --git a/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/vault-unlock/spec.md b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/vault-unlock/spec.md new file mode 100644 index 000000000..90c4b2a1c --- /dev/null +++ b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/specs/vault-unlock/spec.md @@ -0,0 +1,136 @@ +## ADDED Requirements + +### Requirement: Unlock screen +When the active account is locked the popup SHALL show the unlock screen with the account's display name and server host, a Master password field with a show/hide toggle, an "Unlock" button and a "Log out" link. The master password MUST never be stored or sent anywhere; it is used to derive the unlock key and then dropped (ADR-002). + +#### Scenario: Locked account opens the popup +- **GIVEN** the active account has an app password but no private key in `storage.session` +- **WHEN** the popup opens +- **THEN** the unlock screen is shown with the Master password field focused and masked + +#### Scenario: Show password +- **WHEN** the user clicks the show/hide toggle +- **THEN** the field switches between masked and plain text without clearing its value + +### Requirement: Unlock derives the key client-side +Unlock SHALL derive the unlock key from the master password per ADR-003 (PBKDF2-SHA256, 600 000 iterations, salt from the envelope), decrypt the cached suite's `privateKey` envelope, import the result as a non-extractable RSA-OAEP private key and store the PKCS#8 bytes per ADR-002. The server MUST NOT be contacted when the suite row is cached. + +#### Scenario: Correct master password, suite cached +- **GIVEN** `suite.` is present in `storage.local` +- **WHEN** the user submits the correct master password +- **THEN** no network request is made, `privateKeyPkcs8.` and `unlockedAt.` are written to `storage.session`, and the popup shows the unlocked view + +### Requirement: Invalid master password +When the AES-GCM decryption of the envelope fails the extension SHALL show "Invalid master password", clear the field and stay locked. The failure MUST NOT be counted, throttled or reported to the server. + +#### Scenario: Wrong password +- **WHEN** the user submits a wrong master password +- **THEN** "Invalid master password" is shown, the field is empty and focused, and `storage.session` has no key for the account + +### Requirement: Unlock fetches the suite when nothing is cached +When `suite.` is absent the extension SHALL fetch `GET /api/v1/suites` first, cache the `active` row in `storage.local` under `suite.`, and then derive and decrypt. When the fetch fails at the network level the extension MUST show "You are offline and this vault has not been synced yet" and stay locked. + +#### Scenario: First unlock after adding +- **GIVEN** the account was just added +- **WHEN** the user submits the master password +- **THEN** the suites route is called once, the active row is cached and the unlock proceeds + +#### Scenario: First unlock while offline +- **GIVEN** `suite.` is absent +- **WHEN** the suites request fails +- **THEN** the offline message is shown and the master password is discarded + +### Requirement: Cached suite row lifetime +The cached suite row (`id`, `status`, `certificate`, `privateKey`, `unlockKeyEpoch`) SHALL live in `storage.local` under `suite.` and MUST be cleared by logout, account removal, or when a later fetch returns an active suite whose `id` or `unlockKeyEpoch` differs (ADR-002). A differing suite MUST also lock the account. + +#### Scenario: Master password rotated in the web app +- **GIVEN** the account is unlocked with a cached suite of `unlockKeyEpoch` 1 +- **WHEN** a suites fetch returns the active suite with `unlockKeyEpoch` 2 +- **THEN** the cached row is replaced, the private key is purged and the unlock screen is shown + +### Requirement: Private key storage and lifetime +The private key SHALL be held as PKCS#8 bytes (base64) in `storage.session` under `privateKeyPkcs8.` and re-imported as a non-extractable `CryptoKey` each time the background worker starts (ADR-002). On Firefox below 115, where `storage.session` is absent, the bytes MUST live only in the background page's memory. The key is cleared by lock, timeout, browser restart and extension reload, and MUST never be written to `storage.local` except under the "Never" timeout. + +#### Scenario: Service worker restart while unlocked +- **GIVEN** the account is unlocked on Chrome +- **WHEN** the service worker is terminated and woken by the popup +- **THEN** the key is re-imported from `storage.session` and the account is still "Unlocked" + +#### Scenario: Browser restart +- **WHEN** the browser is closed and reopened +- **THEN** every account is "Locked" and `storage.session` is empty + +### Requirement: Manual lock +The unlocked view and the account switcher SHALL offer a "Lock" action that purges the private key from `storage.session` (or memory) and the derived key from memory, and shows the unlock screen. Lock MUST keep the app password, cached suite row, `vaultCache.` and settings. + +#### Scenario: Lock from the unlocked view +- **WHEN** the user clicks "Lock" +- **THEN** `privateKeyPkcs8.` is removed, the unlock screen is shown and no server request is made + +### Requirement: Timeout options and defaults +Each account SHALL have a vault timeout and a timeout action stored in `storage.local` under `settings.`. Options: Immediately, 1 minute, 5 minutes, 15 minutes, 30 minutes, 1 hour, 4 hours, On system lock, On browser restart, Never, Custom (minutes). Actions: Lock, Log out. Defaults: 15 minutes and Lock (ADR-002); a browser restart locks regardless of the chosen option because `storage.session` does not survive it. The maximum timeout and a forced action MUST be read from one policy object so an admin policy can clamp them later. The option picker itself is owned by ext-settings. + +#### Scenario: New account +- **WHEN** an account is added +- **THEN** `settings.` is `{ vaultTimeout: 15, vaultTimeoutAction: 'lock' }` + +### Requirement: Idle timeout enforcement +For a timed option the extension SHALL lock the account when the time since the user's last interaction with the extension reaches the timeout. Last interaction is refreshed by every popup message and stored in `storage.session` as `lastInteractionAt`. The check MUST run on a `browser.alarms` tick at most every minute while any account is unlocked and again on every popup open, so a sleeping worker cannot extend a session. + +#### Scenario: Timeout elapsed while the popup is closed +- **GIVEN** the timeout is 5 minutes and the last interaction was 6 minutes ago +- **WHEN** the alarm fires or the popup opens +- **THEN** the account is locked before any vault state is returned to the popup + +#### Scenario: Popup interaction resets the clock +- **GIVEN** the timeout is 5 minutes +- **WHEN** the user interacts with the popup after 4 minutes +- **THEN** `lastInteractionAt` is refreshed and the account stays unlocked for another 5 minutes + +### Requirement: Immediately and On system lock +"Immediately" SHALL lock the account when the popup closes. "On system lock" SHALL lock the account when `browser.idle` reports the `locked` state. "On browser restart" relies on `storage.session` being cleared and needs no timer. + +#### Scenario: Immediately +- **GIVEN** the timeout is Immediately +- **WHEN** the popup closes +- **THEN** the account is locked before the popup is reopened + +#### Scenario: Operating system locks the screen +- **GIVEN** the timeout is On system lock +- **WHEN** the idle state changes to `locked` +- **THEN** the account is locked + +### Requirement: Timeout action Log out +When the timeout action is "Log out" the elapsed timeout SHALL purge the private key, the app password, the cached suite row and `vaultCache.`, keep the identity record and settings, and leave the account in the "Logged out" state so the "Log in again" screen is shown next. + +#### Scenario: Log out on timeout +- **GIVEN** the action is Log out and the timeout elapsed +- **WHEN** the popup opens +- **THEN** the "Log in again" screen is shown and the account is listed as "Logged out" in the switcher + +### Requirement: Never timeout +Choosing "Never" SHALL show the warning "Your vault stays unlocked until you lock it, and the key is stored on disk" and requires confirmation. With "Never" the PKCS#8 bytes MAY be written to `storage.local` under `neverLockKey.`; this is the only case key material touches `storage.local` (ADR-002). Manual lock, logout, account removal and changing the timeout to anything else MUST delete that key. + +#### Scenario: Never survives a restart +- **GIVEN** the timeout is Never and the account is unlocked +- **WHEN** the browser restarts +- **THEN** the account is still "Unlocked" without asking for the master password + +#### Scenario: Leaving Never +- **GIVEN** `neverLockKey.` exists +- **WHEN** the timeout is changed to any other option +- **THEN** `neverLockKey.` is removed and the key remains only in `storage.session` + +### Requirement: Lock and logout labelled distinctly +Every button, link, status label and message SHALL use "Lock" for purging the key only and "Log out" for purging the app password. The two words MUST never be used for the same action. + +#### Scenario: Unlock screen actions +- **WHEN** the unlock screen is shown +- **THEN** the primary button reads "Unlock" and the secondary link reads "Log out", and no element reads "Lock" + +### Requirement: Alternative unlock method hook +The unlock flow SHALL expose one entry point that takes an unlock method (`masterPassword` today) so ext-settings can add PIN unlock without touching the state machine. The popup MUST NOT show a PIN option in this change. + +#### Scenario: Only master password offered +- **WHEN** the unlock screen renders +- **THEN** the master password is the only unlock method shown diff --git a/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/tasks.md b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/tasks.md new file mode 100644 index 000000000..6a507bd61 --- /dev/null +++ b/openspec/references/keepiq-extension/changes/ext-accounts-and-unlock/tasks.md @@ -0,0 +1,52 @@ +## 1. Manifest and message contract + +- [ ] 1.1 Edit `wxt.config.ts`: add `alarms` and `idle` to `permissions`; add `optional_host_permissions: ['https://*/*', 'http://*/*']` for Chrome and the same patterns under `optional_permissions` for Firefox by branching on the `browser` argument; leave `strict_min_version` at `109.0` and `manifestVersion` unset. + - Both generated manifests contain the two permissions and the optional origin patterns in the right key. +- [ ] 1.2 Rewrite `src/messages.ts` to the contract in design.md (`AccountSummary`, `PopupState`, `ErrorCode`, `Result`, the new `PopupToBackground` arms) and remove `get_state`, `set_enabled`, `enabled_changed` and the old `PopupState`; keep `ContentToBackground` with `page_ready`. + - `npm run typecheck` fails only in `background.ts` and `popup/main.ts`, which later tasks rewrite or delete. +- [ ] 1.3 Install `react` and `react-dom` as dependencies and `@types/react`, `@types/react-dom`, `@wxt-dev/module-react`, `eslint-plugin-react-hooks` as dev dependencies; add `modules: ['@wxt-dev/module-react']` to `wxt.config.ts`; extend `eslint.config.mjs` with the react-hooks recommended rules for `entrypoints/**/*.tsx` (ADR-004). + - `npm run build` succeeds with an empty `main.tsx` and `.output/chrome-mv3/` contains the React chunk. + +## 2. Crypto module + +- [ ] 2.1 Create `src/crypto/base64.ts`, `src/crypto/envelope.ts` (decode the `[version][salt][iv][ciphertext||tag]` layout, reject version not 1) and `src/crypto/kdf.ts` (PBKDF2-SHA256, 600 000 iterations, non-extractable AES-GCM key) per ADR-003. + - The derived key is used for one `decrypt` call and never stored. +- [ ] 2.2 Create `src/crypto/rsa.ts`: PEM to PKCS#8 import (`RSA-OAEP`, `SHA-256`, `extractable: false`, `['decrypt']`), X.509 DER walk to the SPKI and public key import (`['encrypt']`), chunked decrypt (read big-endian count, decrypt 512-byte blocks, concatenate, decode UTF-8 once) and chunked encrypt (446-byte chunks, empty string as one zero-length chunk); export from `src/crypto/index.ts`. + - Compare the DER walk against the web app's handling of the optional `[0]` version tag. +- [ ] 2.3 Add `vitest` as a dev dependency with a `test` script and write `src/crypto/*.test.ts`: envelope encode and decode round trip, wrong-version rejection, PBKDF2 plus AES-GCM round trip with a wrong password failing on the tag, RSA chunked round trip for empty, one-chunk, multi-chunk and multi-byte UTF-8 straddling a chunk boundary. + - Tests run under Node's WebCrypto; no browser needed. + +## 3. API client + +- [ ] 3.1 Create `src/api/types.ts` with the shapes from ADR-003: suite row, secret row as a union of the normal and `blocked: true` shapes, folder, secret type, `{items, total, page, limit}`, offline manifest, user settings, OCS user envelope. +- [ ] 3.2 Create `src/api/client.ts`: `createClient(account)` with base URL, Basic auth, `OCS-APIRequest: true`, `Accept: application/json`, `credentials: 'omit'`, JSON body on writes; error classes `ApiError`, `SessionRevoked`, `VaultWriteLocked`, `Offline`, `KeepiqNotInstalled`; `onUnauthorized` hook; refuse calls when `appPassword` is `null`; `fetchIdentity` (unwrap `ocs.data`) and `fetchAvatarDataUrl` (`/index.php/avatar/{uid}/64` to a data URL, `null` on non-2xx). + - 401 calls the hook exactly once before rejecting; 423 and network failures never call it. + +## 4. Account store + +- [ ] 4.1 Create `src/accounts/normalize-origin.ts`: accept bare host, origin or any `/index.php/apps/keepiq` URL, return the origin, default to `https`, allow `http` only for `localhost`, `127.0.0.1`, `*.test` and `*.local`; return `invalid_url` or `insecure_url` codes. +- [ ] 4.2 Create `src/accounts/store.ts`: `storage.local` schema (`accounts`, `activeAccountId`, `settings.` with defaults 15 minutes and `lock`), `add`, `reauthenticate`, `remove` (purges `accounts[id]`, `settings.`, `suite.`, `vaultCache.`, `neverLockKey.` and the key, then picks the next active account), `removeAll`, `setActive`, `markLoggedOut` (app password, key, suite row and vault cache only), the 5-account limit and the origin plus uid duplicate check; register `markLoggedOut` as the client's `onUnauthorized` hook. + - Status is derived from `appPassword` and the key store, never stored. +- [ ] 4.3 Create `src/accounts/verify.ts`: identity then suites through the client, map failures to `unreachable`, `not_nextcloud`, `unauthorized`, `keepiq_missing`, `no_active_suite`; on success cache the active suite row under `suite.`, fetch the avatar and return the record fields taken from the identity response. + +## 5. Unlock and lock engine + +- [ ] 5.1 Create `src/vault/key-store.ts`: `storage.session` backend when present, module `Map` backend otherwise; `put`, `get` (re-import bytes to a non-extractable `CryptoKey` once per worker generation), `clear`, `clearAll`; read `neverLockKey.` from `storage.local` after a restart when the account's timeout is `never`, and delete it on every clear. +- [ ] 5.2 Create `src/vault/unlock.ts`: `unlock(accountId, method)` that fetches and caches the suite when `suite.` is absent (`offline_no_cache` on network failure), decodes the envelope, derives, decrypts (`invalid_master_password` on GCM failure), imports and stores the key with `unlockedAt.`; `lock`, `lockAll`, `logoutForTimeout`; `checkSuite(row)` that purges the cache and key when `id` or `unlockKeyEpoch` changed. + - The master password string is not retained after `unlock` returns. +- [ ] 5.3 Create `src/vault/timeout.ts`: option and action types, `TIMEOUT_POLICY`, `touch()` writing `lastInteractionAt`, `enforce()` locking or logging out every account past its timeout, `vault-timeout` alarm with `periodInMinutes: 1` created while any account is unlocked and cleared otherwise, `browser.idle.onStateChanged` handling `locked` for the `onSystemLock` option, and popup port `onDisconnect` handling `immediately`. +- [ ] 5.4 Rewrite `entrypoints/background.ts`: remove the flag and badge code; route every `PopupToBackground` arm to the store, unlock and timeout modules and return `Result`; call `enforce()` before answering `vault.status`; register the alarm, idle and `runtime.onConnect` listeners at top level so an MV3 wake-up re-registers them. + - Request/response arms return the promise; nothing returns `true`. + +## 6. Popup + +- [ ] 6.1 Replace `entrypoints/popup/main.ts` with `main.tsx` (mount ``, open the `popup` port), reduce `index.html` to a `#root`, and create `App.tsx`, `hooks/useMessage.ts` (typed `browser.runtime.sendMessage` wrapper resolving to `Result`, the popup's only `unknown` cast) and `hooks/usePopupState.ts` (`vault.status` on mount, `state`, `refresh`, `dispatch`); `App` renders the view for `state.screen`. + - No file under `entrypoints/popup/` imports `src/api` or `src/crypto`. +- [ ] 6.2 Create `components/Header.tsx` (title, avatar slot that opens the switcher), `Avatar.tsx` (data URL or initials disc), `Button.tsx` (busy state), `TextField.tsx` (label, error, show/hide toggle for passwords) and `ErrorBanner.tsx`. +- [ ] 6.3 Create `views/AddAccount.tsx` (three fields, security settings link, `browser.permissions.request` in the submit handler, `ErrorCode` to copy mapping, field values mirrored to `sessionStorage` for the retry) and `views/LogInAgain.tsx` (identity plus one App password field, "Session revoked" banner, "Log out" link calling `accounts.remove`). +- [ ] 6.4 Create `views/Unlock.tsx` (identity, `TextField` for the master password, busy Unlock, "Log out" link, "Invalid master password" and offline errors), `views/Unlocked.tsx` (identity and Lock; replaced by ext-vault-browse) and `views/AccountSwitcher.tsx` (rows with `Avatar`, display name, host, status label, active marker, per-account Lock and Log out, Lock all, Log out all with confirmation, Add account disabled at 5 with the limit hint); extend `popup.css` for these, keeping `color-scheme: light dark` and widening from 280 px to 320 px if the rows need it. + +## 7. Verification + +- [ ] 7.1 Run `grep -rn "chrome\." src/ entrypoints/` (only comments may match), `npm test`, `npm run typecheck`, `npm run lint`, `npm run build` and `npm run build:firefox`; load `.output/chrome-mv3/` and `.output/firefox-mv2/manifest.json` and walk through: add account against the dev backend, each verification error, unlock, wrong master password, manual lock, Immediately, 1 minute timeout with the popup closed, timeout action Log out, 401 after revoking the app password in Nextcloud, switch between two accounts, Log out all. + - Confirm on Firefox that the account stays unlocked across a popup close and locks after a browser restart. diff --git a/openspec/references/keepiq-extension/changes/ext-autofill/.openspec.yaml b/openspec/references/keepiq-extension/changes/ext-autofill/.openspec.yaml new file mode 100644 index 000000000..1b9acb7fd --- /dev/null +++ b/openspec/references/keepiq-extension/changes/ext-autofill/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 diff --git a/openspec/references/keepiq-extension/changes/ext-autofill/design.md b/openspec/references/keepiq-extension/changes/ext-autofill/design.md new file mode 100644 index 000000000..0163e1adf --- /dev/null +++ b/openspec/references/keepiq-extension/changes/ext-autofill/design.md @@ -0,0 +1,117 @@ +## Context + +The earlier chain members give the background an unlocked private key in `storage.session`, a ciphertext snapshot with plaintext `name`, `url`, `typeId`, `folderId` in `storage.local`, lazy decryption, the base-domain matcher `src/vault/match.ts`, the popup's "Autofill suggestions" section, the add form and the settings store (ADR-002). The content script is still the template scaffold. This change connects the vault to web pages the way Bitwarden's first-generation autofill does, and adds the save and update notification bar. + +Constraints: content scripts run in hostile pages and get one credential per fill (ADR-002, config rule). Every API must exist on Chrome MV3 and Firefox MV2 or be shimmed (WXT-AND-BROWSERS.md). Keepiq has no per-item match type and no last-used field (ADR-003). The popup is React (ADR-004); background, content script, notification bar and offscreen document stay plain TypeScript. + +## Goals / Non-Goals + +**Goals:** +- Fill username and password from the popup, the context menu, a keyboard shortcut and, opt-in, on page load. +- Bitwarden's six match rules as a global default, public suffix aware. +- Save new logins and update changed passwords through a notification bar the page cannot tamper with. +- Copy from the background on both browsers with the clipboard clear delay. + +**Non-Goals:** +- Inline menu on form fields, passkey provider, TOTP autofill after fill, card and identity fill (later changes). +- Narrowing content script `matches`; the fill and capture surfaces need to run on any login page, so `*://*/*` stays until the CLAUDE.md open decision is settled. +- Auto-submit after fill (Bitwarden does not). + +## Decisions + +- **Matching in the background, on `sender.url` and `tab.url`.** The content script never tells the background which item to use; it only reports whether it has fields. Alternative: matching in the content script, rejected because it needs vault metadata in page context. +- **`tldts` for the public suffix list.** Bitwarden uses it, it ships a compact list and handles `co.uk` and `github.io` correctly. Alternative: hand-maintained multi-label suffix set, rejected as a maintenance trap. If `src/vault/match.ts` from ext-vault-browse already uses `tldts`, `src/autofill/matcher.ts` wraps it; otherwise this change routes it through `tldts` too so the popup section and autofill agree. +- **Frame discovery by round trip, not `webNavigation`.** The background broadcasts `fill.collect` to the tab; every frame's content script replies `{hasUsername, hasPassword}` and the browser supplies `sender.frameId` and `sender.url`. The background then sends `fill.execute` only to the top frame and to frames whose `sender.url` matches the item, with `tabs.sendMessage(tabId, msg, { frameId })`. Alternative: `webNavigation.getAllFrames`, rejected to avoid another permission. +- **Insecure page confirm is a separate round trip.** For menu, shortcut and page-load fills the background sends `fill.confirmInsecure` to the top frame and waits for the boolean before decrypting. The popup asks in its own UI. Alternative: send the credential with a flag and let the content script confirm, rejected because plaintext would reach the page before consent. +- **Plaintext lifetime.** The background decrypts inside the fill handler, awaits the content replies, then lets the strings go out of scope; nothing is cached. The content script overwrites its local references after writing the fields. Matches ADR-002 "per fill". +- **Field filling dispatches events like Bitwarden.** Set the value through the native `HTMLInputElement.prototype.value` setter (so React's value tracker notices), then dispatch `keydown`, `keypress`, `keyup`, `input`, `change`. Alternative: `element.value = x` only, rejected because frameworks ignore it. +- **Context menu with `browser.contextMenus`.** Firefox exposes `contextMenus` as an alias of `menus` with the same permission name, so no shim. Menus persist across MV3 worker restarts, so the builder does `removeAll` then create, runs on `runtime.onInstalled`, `tabs.onActivated`, `tabs.onUpdated` (url change of the active tab), lock state change and vault sync. `contexts: ['editable']`. Fixed ids per entry, item entries `autofill:`, `copy-user:`, `copy-pass:`. +- **Opening the popup from the background.** `action.openPopup()` where available (Chrome 127+, Firefox from a user-input handler), falling back to `browser.windows.create({ url: browser.runtime.getURL('/popup.html'), type: 'popup' })`, the Bitwarden popout. Lives in `src/browser-action.ts` next to the existing `action` shim. +- **Clipboard from the background.** Chrome MV3 has no DOM in the worker, so `browser.offscreen.createDocument({ url: 'offscreen.html', reasons: ['CLIPBOARD'], justification })` once, then `clipboard.write` messages; guarded by `if (browser.offscreen)`. Firefox MV2 background page calls `navigator.clipboard.writeText` directly under `clipboardWrite`. The clear timer reuses the copy-with-clear helper from ext-vault-browse and schedules through `browser.alarms` so an MV3 worker restart does not lose it. +- **Notification bar is an extension page in an iframe.** `entrypoints/notification/index.html` is listed in `web_accessible_resources`; the content script inserts an `