diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index aa53e3117..1742ec63f 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -172,7 +172,14 @@ jobs: # which reads as an auth problem rather than a typo. Six fleet repos hit # this; in pipelinq it killed all four PHPUnit legs and the E2E job at the # clone step, so those gates had never executed a single test. - additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]' + # + # integriq is here because the Integrations page reads integriq's + # `app_connection` rows (adopt-connection-registry). Without it the page + # shows the missing-dependency screen and + # `tests/e2e/workflows/integrations-page.spec.ts` fails on every run. + # `app` is `integriq`, verified in its appinfo/info.xml on `development` + # on 2026-09-15. + additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"},{"repo":"ConductionNL/integriq","app":"integriq","ref":"development"}]' enable-sbom: true # ── Licensing ──────────────────────────────────────────────────────── diff --git a/appinfo/info.xml b/appinfo/info.xml index 27a3f3825..02cc58ce4 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -36,7 +36,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.3.2-unstable.20260910105221 + 0.3.4-unstable.20260912202807 EUPL-1.2 Conduction Keepiq diff --git a/appinfo/routes.php b/appinfo/routes.php index ad636e242..2f5c4a08c 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -37,8 +37,10 @@ ['name' => 'encryptionSuite#create', 'url' => '/api/v1/suites', 'verb' => 'POST'], ['name' => 'encryptionSuite#updatePrivateKey', 'url' => '/api/v1/suites/{id}/private-key', 'verb' => 'PUT'], ['name' => 'encryptionSuite#revoke', 'url' => '/api/v1/suites/{id}/revoke', 'verb' => 'POST'], + ['name' => 'encryptionSuite#forceRevoke', 'url' => '/api/v1/suites/{id}/force-revoke', 'verb' => 'POST'], ['name' => 'encryptionSuite#reinstate', 'url' => '/api/v1/suites/{id}/reinstate', 'verb' => 'POST'], ['name' => 'encryptionSuite#compromiseRecovery','url' => '/api/v1/suites/compromise-recovery', 'verb' => 'POST'], + ['name' => 'encryptionSuite#proofChallenge', 'url' => '/api/v1/suites/{id}/proof-challenge', 'verb' => 'GET'], // CA management (admin-only). ['name' => 'cACertificate#getStatus', 'url' => '/api/v1/ca/status', 'verb' => 'GET'], @@ -49,6 +51,7 @@ // Migration tracking. ['name' => 'migration#getStatus', 'url' => '/api/v1/migrations/status', 'verb' => 'GET'], ['name' => 'migration#complete', 'url' => '/api/v1/migrations/{id}/complete', 'verb' => 'POST'], + ['name' => 'migration#abort', 'url' => '/api/v1/migrations/{id}/abort', 'verb' => 'POST'], // Compromise-recovery migration work loop. One record per request: the // browser decrypts with the old private key, re-encrypts under the new one, @@ -59,6 +62,10 @@ ['name' => 'migration#reEncryptSecret', 'url' => '/api/v1/migrations/{id}/secrets/{secretId}', 'verb' => 'POST'], ['name' => 'migration#reEncryptVersion', 'url' => '/api/v1/migrations/{id}/versions/{versionId}', 'verb' => 'POST'], ['name' => 'migration#reEncryptAttachmentGrant', 'url' => '/api/v1/migrations/{id}/attachment-grants/{grantId}', 'verb' => 'POST'], + // Emergency contacts migrate too, but off the gate: the browser mints a fresh + // envelope escrowing the new key and re-points the contact here. A contact it + // cannot carry is left for the completion sweep to invalidate. + ['name' => 'migration#reEnvelopeEmergencyContact', 'url' => '/api/v1/migrations/{id}/emergency-contacts/{contactId}', 'verb' => 'POST'], // Key generator endpoint (stateless, authenticated). ['name' => 'keyGenerator#generate', 'url' => '/api/v1/generate-key', 'verb' => 'POST'], diff --git a/composer.json b/composer.json index 3f5985b3b..8269621b3 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "conduction/hydra-gates": "^1.8.2", "cyclonedx/cyclonedx-php-composer": "^6.2", "edgedesign/phpqa": "^1.27", - "nextcloud/ocp": "^34.0", + "nextcloud/ocp": "^35.0", "phpcsstandards/phpcsextra": "^1.4", "phpmd/phpmd": "^2.15", "phpmetrics/phpmetrics": "^2.8", diff --git a/composer.lock b/composer.lock index b39e2660a..affe27079 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "504c3f4ea0aef588c28a1eec0737bebf", + "content-hash": "390fc6d7ee21a2332cff53180c9bfc98", "packages": [ { "name": "brick/math", @@ -498,20 +498,20 @@ }, { "name": "spomky-labs/pki-framework", - "version": "1.6.2", + "version": "1.6.3", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "8f333bebe104ea24f1a160a6fb69e2dead4f1882" + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/8f333bebe104ea24f1a160a6fb69e2dead4f1882", - "reference": "8f333bebe104ea24f1a160a6fb69e2dead4f1882", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/792e909d4e387adffe3c4f404451c7d57a3d2022", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", "ext-mbstring": "*", "php": ">=8.1" }, @@ -592,7 +592,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.2" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.3" }, "funding": [ { @@ -604,7 +604,7 @@ "type": "patreon" } ], - "time": "2026-09-08T07:15:08+00:00" + "time": "2026-09-12T19:02:49+00:00" }, { "name": "symfony/deprecation-contracts", @@ -679,20 +679,20 @@ }, { "name": "web-token/jwt-library", - "version": "4.2.2", + "version": "4.2.3", "source": { "type": "git", "url": "https://github.com/web-token/jwt-library.git", - "reference": "ae642340ee2ca91ca0c37edd72a9d06302651ca9" + "reference": "db962c3ad8bc91c531a49dd373f0a641fc507bfd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-token/jwt-library/zipball/ae642340ee2ca91ca0c37edd72a9d06302651ca9", - "reference": "ae642340ee2ca91ca0c37edd72a9d06302651ca9", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/db962c3ad8bc91c531a49dd373f0a641fc507bfd", + "reference": "db962c3ad8bc91c531a49dd373f0a641fc507bfd", "shasum": "" }, "require": { - "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20", + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", "php": ">=8.2", "psr/clock": "^1.0", "spomky-labs/pki-framework": "^1.2.1", @@ -753,7 +753,7 @@ ], "support": { "issues": "https://github.com/web-token/jwt-library/issues", - "source": "https://github.com/web-token/jwt-library/tree/4.2.2" + "source": "https://github.com/web-token/jwt-library/tree/4.2.3" }, "funding": [ { @@ -765,7 +765,7 @@ "type": "patreon" } ], - "time": "2026-08-30T13:53:36+00:00" + "time": "2026-09-12T18:50:35+00:00" } ], "packages-dev": [ @@ -1278,16 +1278,16 @@ }, { "name": "conduction/hydra-gates", - "version": "v1.16.1", + "version": "v1.18.0", "source": { "type": "git", "url": "https://github.com/ConductionNL/.github.git", - "reference": "bfb34cc6caa9762f6aa3da66f9a2b573f8442358" + "reference": "477f930e84f1b3e709d2fb645d7c303d8d39a994" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ConductionNL/.github/zipball/bfb34cc6caa9762f6aa3da66f9a2b573f8442358", - "reference": "bfb34cc6caa9762f6aa3da66f9a2b573f8442358", + "url": "https://api.github.com/repos/ConductionNL/.github/zipball/477f930e84f1b3e709d2fb645d7c303d8d39a994", + "reference": "477f930e84f1b3e709d2fb645d7c303d8d39a994", "shasum": "" }, "require": { @@ -1326,9 +1326,9 @@ "support": { "docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md", "issues": "https://github.com/ConductionNL/.github/issues", - "source": "https://github.com/ConductionNL/.github/tree/v1.16.1" + "source": "https://github.com/ConductionNL/.github/tree/v1.18.0" }, - "time": "2026-09-07T08:01:54+00:00" + "time": "2026-09-10T10:15:53+00:00" }, { "name": "consolidation/annotated-command", @@ -2636,30 +2636,34 @@ }, { "name": "nextcloud/ocp", - "version": "v34.0.3", + "version": "v35.0.0", "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "3fb764be792476e4dcf1593101d978fc1dc8ac9a" + "reference": "94d85a0ba6b3b3911c25b2eddb0ca0fe5691acbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/3fb764be792476e4dcf1593101d978fc1dc8ac9a", - "reference": "3fb764be792476e4dcf1593101d978fc1dc8ac9a", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/94d85a0ba6b3b3911c25b2eddb0ca0fe5691acbf", + "reference": "94d85a0ba6b3b3911c25b2eddb0ca0fe5691acbf", "shasum": "" }, "require": { - "php": "~8.2 || ~8.3 || ~8.4 || ~8.5", + "php": "~8.3 || ~8.4 || ~8.5", "psr/clock": "^1.0", "psr/container": "^2.0.2", "psr/event-dispatcher": "^1.0", "psr/http-client": "^1.0.3", - "psr/log": "^3.0.2" + "psr/log": "^3.0.2", + "symfony/polyfill-intl-normalizer": "^1.38", + "symfony/polyfill-php84": "^1.38", + "symfony/polyfill-php85": "^1.41", + "symfony/polyfill-php86": "^1.41" }, "type": "library", "extra": { "branch-alias": { - "dev-stable34": "34.0.0-dev" + "dev-stable35": "35.0.0-dev" } }, "notification-url": "https://packagist.org/downloads/", @@ -2679,9 +2683,9 @@ "description": "Composer package containing Nextcloud's public OCP API and the unstable NCU API", "support": { "issues": "https://github.com/nextcloud-deps/ocp/issues", - "source": "https://github.com/nextcloud-deps/ocp/tree/v34.0.3" + "source": "https://github.com/nextcloud-deps/ocp/tree/v35.0.0" }, - "time": "2026-08-07T02:03:36+00:00" + "time": "2026-09-04T01:52:36+00:00" }, { "name": "nikic/php-parser", @@ -3931,11 +3935,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.13", + "version": "2.2.14", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", - "reference": "9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", + "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", "shasum": "" }, "require": { @@ -3991,7 +3995,7 @@ "type": "github" } ], - "time": "2026-09-03T20:38:19+00:00" + "time": "2026-09-12T21:39:33+00:00" }, { "name": "phpunit/php-code-coverage", @@ -7643,16 +7647,16 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -7704,7 +7708,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -7724,7 +7728,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -7891,6 +7895,246 @@ ], "time": "2026-05-26T12:45:58+00:00" }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-02T13:42:24+00:00" + }, { "name": "symfony/process", "version": "v7.4.13", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c10c3e194..50f018a46 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -344,6 +344,32 @@ Tracks compromise recovery migrations. | `started_at` | datetime | — | | `completed_at` | datetime | Null while in progress | +**Emergency-access recovery envelopes are a migrated store, not a casualty.** +A compromise-recovery rotation re-encrypts every suite-bound store under the new +key; emergency contacts are the one store not produced by decrypt-then-re-encrypt. +The rotating owner holds the new private key and can fetch each grantee's current +certificate, so the browser mints a *fresh* recovery envelope escrowing the new +key (`buildRecoveryEnvelope` — a build, never a re-wrap of the old envelope) and +re-points the contact to the new suite, keeping it `granted` +(`MigrationController::reEnvelopeEmergencyContact` → +`EmergencyEnvelopeInvalidationService::reEnvelopeForRotation`). The server cannot +open the envelope (only the grantee can), so it shape-checks it and asserts the +declared grantee suite is the grantee's *current* active suite rather than +round-tripping it. Emergency contacts are deliberately **outside** the completion +gate: a grantee with no reachable certificate can never be re-enveloped, and +gating on one would wedge the vault, so such a contact is left on the old suite. + +`EmergencyEnvelopeInvalidationService::invalidateForGrantorRotation`, fired by +`EmergencyAccessSuiteRotationListener` on `SuiteMigrationCompletedEvent`, is now a +**residual sweep**, not a blanket invalidation: the re-enveloped contacts have +already left the old suite, so the sweep finds only the residual (unreachable +grantees), invalidates exactly those, and the client surfaces them for the owner +to re-establish. Revocation still clears the envelopes outright — it produces no +new key to migrate to — but `EncryptionSuiteController::revoke` now refuses while +a usable emergency contact exists unless `acceptEmergencyLoss` is given, and the +refusal surfaces the count (never the identities) so the destruction is a knowing +choice. + ### 3.3 Encryption Flow Summary ``` @@ -585,6 +611,67 @@ All limits are keyed anonymously (per-IP) by Nextcloud's rate-limiter middleware, which is available since NC 24; Keepiq's `info.xml` floor (NC 31) already satisfies this. +### 4.2 Vault-key proofs on destructive operations + +The always-E2E model (ADR-003) makes *reading* the vault cryptographically +gated on the master password, but leaves *writing* gated only by the Nextcloud +session, because writing a secret needs only the owner's public key. For the +operations that can render vault contents or key material **permanently +unreadable**, a session is not enough: those carry a **vault-key proof** — a +signature, made with the owner's suite private key, over a server-issued +challenge bound to the operation's own parameters. Because that private key is +recoverable only by decrypting its envelope with the master password, a verified +proof is a server-verifiable proof of the master password. This closes the +session-only lockout (issue #395): a stolen cookie, leaked app password, or XSS +in an unlocked tab can no longer destroy a vault. + +Enforced declaratively by `#[VaultKeyProofRequired(binds, subject, purpose)]` +(`lib/Attribute/`) + `VaultKeyProofMiddleware`, with the crypto in +`VaultKeyProofService`. The guarded routes: + +| Controller::method | `subject` | `binds` | `purpose` | +|---|---|---|---| +| `EncryptionSuiteController::compromiseRecovery` | `active` (old suite) | `publicKey`, `encryptedPrivateKey` | `compromise-recovery` | +| `EncryptionSuiteController::updatePrivateKey` | `routeParam:id` | `encryptedPrivateKey` | `update-private-key` | +| `EncryptionSuiteController::revoke` | `routeParam:id` | `reason` | `revoke-suite` | +| `MigrationController::complete` | `migrationOldSuite` | `id`, `hasErrors`, `acceptUnrecoverable` | `complete-migration` | +| `EmergencyAccessController::destroy` | `active` | `id` | `emergency-access-destroy` | + +Load-bearing design points — change these only deliberately: + +- **Sign, never decrypt.** The proof is a *signature*. A decrypt challenge would + be satisfiable by the session `CryptoKey`, which is imported non-extractable + and `['decrypt']`-only — so an unlocked tab (and thus injected script) could + answer it. Signing needs the raw private key, re-imported with `['sign']` from + bytes that exist only while the freshly entered master password is in hand. +- **The attribute carries the binding.** The middleware cannot read the request + body (the framework decodes JSON and drops the raw bytes), so the proof + commits to *named* request parameters, each hashed and concatenated in + declared order. No JSON-canonicalisation agreement between JS and PHP is + needed; cross-language interop is pinned by `VaultKeyProofCrossImplTest`. +- **Stateless, expiring challenges.** The nonce is HMAC-authenticated with the + instance secret over its random part, the caller, the purpose and an expiry — + no server-side store. Deliberately **not** `ICacheFactory`: a null cache on a + default install would break the flow. Single-use enforcement is unnecessary + because the signature commits to the operation's parameters, so a replay only + ever re-authorises the byte-identical operation. +- **Not waived for any session type.** The middleware consults no auth backend + and no token scope, so it behaves identically on SSO, app-password and + ordinary sessions — its authority is key material, not the login method. +- **`complete` proves the OLD key** (`migrationOldSuite`), not the new one: at + completion both suites are active so `active` is ambiguous, and the old key is + the one both the initiate and resume clients already hold the password for. +- **Abort is deliberately unguarded.** `MigrationController::abort` is + restorative (it returns the vault to the still-active old suite), so requiring + a proof would leave a vault wedged by an unauthorised rotation wedged. + +**A new route that can irreversibly destroy vault data MUST be added to +`tests/Unit/Controller/VaultKeyProofAttributesTest.php`.** A declarative guard +fails *open* when it is omitted — nothing errors, the attribute is just absent — +so that reflection test enumerates the guarded routes and fails the build if one +loses its attribute or has its binding/subject/purpose loosened. The test also +carries a documented exclusion list (`proofChallenge`, `abort`). + ## 5. Open Research Questions 1. **Application API authentication** — RFC 7523 (JWT Bearer / Private Key JWT) is the lean for how approved applications authenticate to retrieve secrets. Uses existing RSA key infrastructure, short-lived tokens, no new credential. Needs team discussion before finalizing. See [application-mgmt spec](../openspec/specs/application-mgmt/spec.md). diff --git a/l10n/be.js b/l10n/be.js index 3642e3ce2..c3f1f8e22 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Перабраць як адміністратар сховішча", "Select {name}": "Выбраць {name}", "Could not load the password policy.": "Не ўдалося загрузіць палітыку пароляў.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Дадана {ok} з {total} сакрэтаў у камандную папку", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Пашырэнне Keepiq для браўзера аўтаматычна запаўняе вашы лагіны, дае ключы доступу і паказвае коды TOTP — і вашы сакрэты ніколі не пакідаюць вашу прыладу.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Ствараецца запаўняльнік, які застаецца пустым, пакуль атрымальнік яго не запоўніць — вам ніколі не трэба выдумляць значэнне.", - "Could not reach the directory": "Не ўдалося звязацца з каталогам" + "Could not reach the directory": "Не ўдалося звязацца з каталогам", + "Integrations": "Інтэграцыі", + "Connection": "Злучэнне", + "Status message": "Паведамленне пра стан", + "Last checked": "Апошняя праверка", + "All connections": "Усе злучэнні", + "Add integration": "Дадаць інтэграцыю", + "Open settings": "Адкрыць налады", + "Configured": "Наладжана", + "Limited": "Абмежавана", + "Simulated": "Імітавана", + "Not available": "Недаступна", + "Error": "Памылка", + "e.g. Offboarding, device lost, key compromised": "напр. звальненне, страта прылады, кампраметацыя ключа", + "Encryption suites": "Наборы шыфравання", + "Failed to force-revoke suite": "Не ўдалося прымусова адклікаць набор", + "Failed to reinstate suite": "Не ўдалося аднавіць набор", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Прымусова адклікаць набор шыфравання, які належыць карыстальніку або дадатку, па id, калі яго ўладальнік не можа (забыты галоўны пароль, адкліканы доступ або кампраметацыя), і аднавіць адкліканы. Прымусовае адкліканне просіць паўторна пацвердзіць ваш уласны пароль і назаўжды выдаляе аварыйны доступ набору.", + "Force-revoke suite": "Прымусова адклікаць набор", + "Reinstate suite": "Аднавіць набор", + "Revoking this suite deleted %n emergency-access contact.": "Адкліканне гэтага набору выдаліла %n кантакт аварыйнага доступу.", + "Revoking this suite deleted %n emergency-access contacts.": "Адкліканне гэтага набору выдаліла %n кантактаў аварыйнага доступу.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Лічыць сакрэты набору скампраметаванымі (пазначыць для ратацыі і апавясціць уладальнікаў)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/be.json b/l10n/be.json index afb9135e9..1be79921c 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Перабраць як адміністратар сховішча", "Select {name}": "Выбраць {name}", "Could not load the password policy.": "Не ўдалося загрузіць палітыку пароляў.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Дадана {ok} з {total} сакрэтаў у камандную папку", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Пашырэнне Keepiq для браўзера аўтаматычна запаўняе вашы лагіны, дае ключы доступу і паказвае коды TOTP — і вашы сакрэты ніколі не пакідаюць вашу прыладу.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Ствараецца запаўняльнік, які застаецца пустым, пакуль атрымальнік яго не запоўніць — вам ніколі не трэба выдумляць значэнне.", - "Could not reach the directory": "Не ўдалося звязацца з каталогам" + "Could not reach the directory": "Не ўдалося звязацца з каталогам", + "Integrations": "Інтэграцыі", + "Connection": "Злучэнне", + "Status message": "Паведамленне пра стан", + "Last checked": "Апошняя праверка", + "All connections": "Усе злучэнні", + "Add integration": "Дадаць інтэграцыю", + "Open settings": "Адкрыць налады", + "Configured": "Наладжана", + "Limited": "Абмежавана", + "Simulated": "Імітавана", + "Not available": "Недаступна", + "Error": "Памылка", + "e.g. Offboarding, device lost, key compromised": "напр. звальненне, страта прылады, кампраметацыя ключа", + "Encryption suites": "Наборы шыфравання", + "Failed to force-revoke suite": "Не ўдалося прымусова адклікаць набор", + "Failed to reinstate suite": "Не ўдалося аднавіць набор", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Прымусова адклікаць набор шыфравання, які належыць карыстальніку або дадатку, па id, калі яго ўладальнік не можа (забыты галоўны пароль, адкліканы доступ або кампраметацыя), і аднавіць адкліканы. Прымусовае адкліканне просіць паўторна пацвердзіць ваш уласны пароль і назаўжды выдаляе аварыйны доступ набору.", + "Force-revoke suite": "Прымусова адклікаць набор", + "Reinstate suite": "Аднавіць набор", + "Revoking this suite deleted %n emergency-access contact.": "Адкліканне гэтага набору выдаліла %n кантакт аварыйнага доступу.", + "Revoking this suite deleted %n emergency-access contacts.": "Адкліканне гэтага набору выдаліла %n кантактаў аварыйнага доступу.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Лічыць сакрэты набору скампраметаванымі (пазначыць для ратацыі і апавясціць уладальнікаў)" }, "plurals": null } diff --git a/l10n/bg.js b/l10n/bg.js index 4585e96b0..dce09720e 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Поемане като администратор на трезора", "Select {name}": "Избиране на {name}", "Could not load the password policy.": "Политиката за пароли не можа да бъде заредена.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Добавени {ok} от {total} тайни към екипната папка", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Разширението Keepiq за браузър попълва автоматично данните ви за вход, предоставя ключове за достъп и показва кодове TOTP — без тайните ви никога да напускат устройството ви.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Създава се запазено място, което остава празно, докато получателят не го попълни — никога не се налага да измисляте стойност.", - "Could not reach the directory": "Указателят не можа да бъде достигнат" + "Could not reach the directory": "Указателят не можа да бъде достигнат", + "Integrations": "Интеграции", + "Connection": "Връзка", + "Status message": "Съобщение за състояние", + "Last checked": "Последна проверка", + "All connections": "Всички връзки", + "Add integration": "Добавяне на интеграция", + "Open settings": "Отваряне на настройките", + "Configured": "Конфигурирано", + "Limited": "Ограничено", + "Simulated": "Симулирано", + "Not available": "Не е налично", + "Error": "Грешка", + "e.g. Offboarding, device lost, key compromised": "напр. напускане, изгубено устройство, компрометиран ключ", + "Encryption suites": "Комплекти за шифроване", + "Failed to force-revoke suite": "Принудителното отменяне на комплекта е неуспешно", + "Failed to reinstate suite": "Възстановяването на комплекта е неуспешно", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Принудително отмени комплект за шифроване, собственост на потребител или приложение, по id, когато собственикът му не може (забравена главна парола, отнет достъп или компрометиране), и възстанови отменен. Принудителното отменяне изисква повторно потвърждаване на собствената ви парола и трайно премахва аварийния достъп на комплекта.", + "Force-revoke suite": "Принудително отмени комплекта", + "Reinstate suite": "Възстанови комплекта", + "Revoking this suite deleted %n emergency-access contact.": "Отменянето на този комплект премахна %n контакт за авариен достъп.", + "Revoking this suite deleted %n emergency-access contacts.": "Отменянето на този комплект премахна %n контакта за авариен достъп.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Третирай тайните на комплекта като компрометирани (маркирай за ротация и уведоми собствениците)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index be7c09bb4..ee6c528e4 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Поемане като администратор на трезора", "Select {name}": "Избиране на {name}", "Could not load the password policy.": "Политиката за пароли не можа да бъде заредена.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Добавени {ok} от {total} тайни към екипната папка", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Разширението Keepiq за браузър попълва автоматично данните ви за вход, предоставя ключове за достъп и показва кодове TOTP — без тайните ви никога да напускат устройството ви.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Създава се запазено място, което остава празно, докато получателят не го попълни — никога не се налага да измисляте стойност.", - "Could not reach the directory": "Указателят не можа да бъде достигнат" + "Could not reach the directory": "Указателят не можа да бъде достигнат", + "Integrations": "Интеграции", + "Connection": "Връзка", + "Status message": "Съобщение за състояние", + "Last checked": "Последна проверка", + "All connections": "Всички връзки", + "Add integration": "Добавяне на интеграция", + "Open settings": "Отваряне на настройките", + "Configured": "Конфигурирано", + "Limited": "Ограничено", + "Simulated": "Симулирано", + "Not available": "Не е налично", + "Error": "Грешка", + "e.g. Offboarding, device lost, key compromised": "напр. напускане, изгубено устройство, компрометиран ключ", + "Encryption suites": "Комплекти за шифроване", + "Failed to force-revoke suite": "Принудителното отменяне на комплекта е неуспешно", + "Failed to reinstate suite": "Възстановяването на комплекта е неуспешно", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Принудително отмени комплект за шифроване, собственост на потребител или приложение, по id, когато собственикът му не може (забравена главна парола, отнет достъп или компрометиране), и възстанови отменен. Принудителното отменяне изисква повторно потвърждаване на собствената ви парола и трайно премахва аварийния достъп на комплекта.", + "Force-revoke suite": "Принудително отмени комплекта", + "Reinstate suite": "Възстанови комплекта", + "Revoking this suite deleted %n emergency-access contact.": "Отменянето на този комплект премахна %n контакт за авариен достъп.", + "Revoking this suite deleted %n emergency-access contacts.": "Отменянето на този комплект премахна %n контакта за авариен достъп.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Третирай тайните на комплекта като компрометирани (маркирай за ротация и уведоми собствениците)" }, "plurals": null } diff --git a/l10n/bs.js b/l10n/bs.js index 800559431..142a293bc 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Preuzmi kao administrator trezora", "Select {name}": "Odaberi {name}", "Could not load the password policy.": "Pravila lozinki nije moguće učitati.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Dodano {ok} od {total} tajni u timsku mapu", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Proširenje preglednika Keepiq automatski ispunjava vaše prijave, pruža pristupne ključeve i prikazuje TOTP kodove — a vaše tajne pritom nikada ne izlaze s vašeg uređaja.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Kreira se rezervirano mjesto koje ostaje prazno dok ga primalac ne ispuni — nikada ne morate izmišljati vrijednost.", - "Could not reach the directory": "Do imenika nije bilo moguće doći" + "Could not reach the directory": "Do imenika nije bilo moguće doći", + "Integrations": "Integracije", + "Connection": "Veza", + "Status message": "Poruka o statusu", + "Last checked": "Posljednja provjera", + "All connections": "Sve veze", + "Add integration": "Dodaj integraciju", + "Open settings": "Otvori postavke", + "Configured": "Konfigurirano", + "Limited": "Ograničeno", + "Simulated": "Simulirano", + "Not available": "Nije dostupno", + "Error": "Greška", + "e.g. Offboarding, device lost, key compromised": "npr. odlazak zaposlenika, izgubljeni uređaj, ključ kompromitiran", + "Encryption suites": "Kompleti šifriranja", + "Failed to force-revoke suite": "Prisilno opozivanje kompleta nije uspjelo", + "Failed to reinstate suite": "Vraćanje kompleta nije uspjelo", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Prisilno opozovi komplet šifriranja u vlasništvu korisnika ili aplikacije prema id-u kada njegov vlasnik to ne može (zaboravljena glavna lozinka, opozvani pristup ili kompromitacija) i vrati opozvani. Prisilno opozivanje traži da ponovo potvrdite vlastitu lozinku i trajno briše hitni pristup kompleta.", + "Force-revoke suite": "Prisilno opozovi komplet", + "Reinstate suite": "Vrati komplet", + "Revoking this suite deleted %n emergency-access contact.": "Opozivanje ovog kompleta izbrisalo je %n kontakt hitnog pristupa.", + "Revoking this suite deleted %n emergency-access contacts.": "Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tretiraj tajne kompleta kao kompromitirane (označi za rotaciju i obavijesti vlasnike)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.json b/l10n/bs.json index 0948488a2..63c8ea4ee 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Preuzmi kao administrator trezora", "Select {name}": "Odaberi {name}", "Could not load the password policy.": "Pravila lozinki nije moguće učitati.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Dodano {ok} od {total} tajni u timsku mapu", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Proširenje preglednika Keepiq automatski ispunjava vaše prijave, pruža pristupne ključeve i prikazuje TOTP kodove — a vaše tajne pritom nikada ne izlaze s vašeg uređaja.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Kreira se rezervirano mjesto koje ostaje prazno dok ga primalac ne ispuni — nikada ne morate izmišljati vrijednost.", - "Could not reach the directory": "Do imenika nije bilo moguće doći" + "Could not reach the directory": "Do imenika nije bilo moguće doći", + "Integrations": "Integracije", + "Connection": "Veza", + "Status message": "Poruka o statusu", + "Last checked": "Posljednja provjera", + "All connections": "Sve veze", + "Add integration": "Dodaj integraciju", + "Open settings": "Otvori postavke", + "Configured": "Konfigurirano", + "Limited": "Ograničeno", + "Simulated": "Simulirano", + "Not available": "Nije dostupno", + "Error": "Greška", + "e.g. Offboarding, device lost, key compromised": "npr. odlazak zaposlenika, izgubljeni uređaj, ključ kompromitiran", + "Encryption suites": "Kompleti šifriranja", + "Failed to force-revoke suite": "Prisilno opozivanje kompleta nije uspjelo", + "Failed to reinstate suite": "Vraćanje kompleta nije uspjelo", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Prisilno opozovi komplet šifriranja u vlasništvu korisnika ili aplikacije prema id-u kada njegov vlasnik to ne može (zaboravljena glavna lozinka, opozvani pristup ili kompromitacija) i vrati opozvani. Prisilno opozivanje traži da ponovo potvrdite vlastitu lozinku i trajno briše hitni pristup kompleta.", + "Force-revoke suite": "Prisilno opozovi komplet", + "Reinstate suite": "Vrati komplet", + "Revoking this suite deleted %n emergency-access contact.": "Opozivanje ovog kompleta izbrisalo je %n kontakt hitnog pristupa.", + "Revoking this suite deleted %n emergency-access contacts.": "Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tretiraj tajne kompleta kao kompromitirane (označi za rotaciju i obavijesti vlasnike)" }, "plurals": null } diff --git a/l10n/ca.js b/l10n/ca.js index 989b0eab3..ae8d0a426 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Assumeix el control com a administrador de la caixa forta", "Select {name}": "Selecciona {name}", "Could not load the password policy.": "No s'ha pogut carregar la política de contrasenyes.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "S'han afegit {ok} de {total} secrets a la carpeta d'equip", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "L'extensió de navegador de Keepiq emplena automàticament els vostres inicis de sessió, proporciona claus d'accés i mostra codis TOTP, sense que els vostres secrets surtin mai del vostre dispositiu.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Es crea un marcador que queda buit fins que el destinatari l’ompli: no has d’inventar mai cap valor.", - "Could not reach the directory": "No s'ha pogut connectar amb el directori" + "Could not reach the directory": "No s'ha pogut connectar amb el directori", + "Integrations": "Integracions", + "Connection": "Connexió", + "Status message": "Missatge d'estat", + "Last checked": "Última comprovació", + "All connections": "Totes les connexions", + "Add integration": "Afegeix una integració", + "Open settings": "Obre la configuració", + "Configured": "Configurat", + "Limited": "Limitat", + "Simulated": "Simulat", + "Not available": "No disponible", + "Error": "Error", + "e.g. Offboarding, device lost, key compromised": "p. ex. baixa, dispositiu perdut, clau compromesa", + "Encryption suites": "Suites de xifratge", + "Failed to force-revoke suite": "Ha fallat la revocació forçada de la suite", + "Failed to reinstate suite": "Ha fallat el restabliment de la suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revoca de manera forçada una suite de xifratge propietat d'un usuari o d'una aplicació per id quan el seu propietari no pot (una contrasenya mestra oblidada, un accés revocat o un compromís), i restableix-ne una de revocada. La revocació forçada et demana que tornis a confirmar la teva pròpia contrasenya i elimina permanentment l'accés d'emergència de la suite.", + "Force-revoke suite": "Revoca la suite de manera forçada", + "Reinstate suite": "Restableix la suite", + "Revoking this suite deleted %n emergency-access contact.": "La revocació d'aquesta suite ha eliminat %n contacte d'accés d'emergència.", + "Revoking this suite deleted %n emergency-access contacts.": "La revocació d'aquesta suite ha eliminat %n contactes d'accés d'emergència.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tracta els secrets de la suite com a compromesos (marca per a rotació i notifica els propietaris)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index 6f18eb182..af140bb3f 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Assumeix el control com a administrador de la caixa forta", "Select {name}": "Selecciona {name}", "Could not load the password policy.": "No s'ha pogut carregar la política de contrasenyes.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "S'han afegit {ok} de {total} secrets a la carpeta d'equip", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "L'extensió de navegador de Keepiq emplena automàticament els vostres inicis de sessió, proporciona claus d'accés i mostra codis TOTP, sense que els vostres secrets surtin mai del vostre dispositiu.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Es crea un marcador que queda buit fins que el destinatari l’ompli: no has d’inventar mai cap valor.", - "Could not reach the directory": "No s'ha pogut connectar amb el directori" + "Could not reach the directory": "No s'ha pogut connectar amb el directori", + "Integrations": "Integracions", + "Connection": "Connexió", + "Status message": "Missatge d'estat", + "Last checked": "Última comprovació", + "All connections": "Totes les connexions", + "Add integration": "Afegeix una integració", + "Open settings": "Obre la configuració", + "Configured": "Configurat", + "Limited": "Limitat", + "Simulated": "Simulat", + "Not available": "No disponible", + "Error": "Error", + "e.g. Offboarding, device lost, key compromised": "p. ex. baixa, dispositiu perdut, clau compromesa", + "Encryption suites": "Suites de xifratge", + "Failed to force-revoke suite": "Ha fallat la revocació forçada de la suite", + "Failed to reinstate suite": "Ha fallat el restabliment de la suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revoca de manera forçada una suite de xifratge propietat d'un usuari o d'una aplicació per id quan el seu propietari no pot (una contrasenya mestra oblidada, un accés revocat o un compromís), i restableix-ne una de revocada. La revocació forçada et demana que tornis a confirmar la teva pròpia contrasenya i elimina permanentment l'accés d'emergència de la suite.", + "Force-revoke suite": "Revoca la suite de manera forçada", + "Reinstate suite": "Restableix la suite", + "Revoking this suite deleted %n emergency-access contact.": "La revocació d'aquesta suite ha eliminat %n contacte d'accés d'emergència.", + "Revoking this suite deleted %n emergency-access contacts.": "La revocació d'aquesta suite ha eliminat %n contactes d'accés d'emergència.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tracta els secrets de la suite com a compromesos (marca per a rotació i notifica els propietaris)" }, "plurals": null } diff --git a/l10n/cs.js b/l10n/cs.js index 5ecd154d8..c34202616 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Převzít jako správce trezoru", "Select {name}": "Vybrat {name}", "Could not load the password policy.": "Zásady hesel se nepodařilo načíst.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Přidáno {ok} z {total} tajemství do týmové složky", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Rozšíření prohlížeče Keepiq automaticky vyplňuje vaše přihlašovací údaje, poskytuje přístupové klíče a zobrazuje kódy TOTP — a vaše tajemství přitom nikdy neopustí vaše zařízení.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Vytvoří se zástupný záznam, který zůstane prázdný, dokud jej příjemce nevyplní — nikdy si nemusíte hodnotu vymýšlet.", - "Could not reach the directory": "Adresář se nepodařilo kontaktovat" + "Could not reach the directory": "Adresář se nepodařilo kontaktovat", + "Integrations": "Integrace", + "Connection": "Připojení", + "Status message": "Zpráva o stavu", + "Last checked": "Naposledy zkontrolováno", + "All connections": "Všechna připojení", + "Add integration": "Přidat integraci", + "Open settings": "Otevřít nastavení", + "Configured": "Nastaveno", + "Limited": "Omezeno", + "Simulated": "Simulováno", + "Not available": "Není k dispozici", + "Error": "Chyba", + "e.g. Offboarding, device lost, key compromised": "např. odchod zaměstnance, ztracené zařízení, kompromitovaný klíč", + "Encryption suites": "Šifrovací sady", + "Failed to force-revoke suite": "Vynucené odvolání sady se nezdařilo", + "Failed to reinstate suite": "Obnovení sady se nezdařilo", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Vynuceně odvolat šifrovací sadu vlastněnou uživatelem nebo aplikací podle id, když to její vlastník nemůže (zapomenuté hlavní heslo, odebraný přístup nebo kompromitace), a obnovit odvolanou. Vynucené odvolání vás požádá o opětovné potvrzení vlastního hesla a trvale odstraní nouzový přístup sady.", + "Force-revoke suite": "Vynuceně odvolat sadu", + "Reinstate suite": "Obnovit sadu", + "Revoking this suite deleted %n emergency-access contact.": "Odvolání této sady odstranilo %n kontakt nouzového přístupu.", + "Revoking this suite deleted %n emergency-access contacts.": "Odvolání této sady odstranilo %n kontaktů nouzového přístupu.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Považovat tajemství sady za kompromitovaná (označit k obměně a upozornit vlastníky)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.json b/l10n/cs.json index 67f32e1bf..8aec8c6f0 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Převzít jako správce trezoru", "Select {name}": "Vybrat {name}", "Could not load the password policy.": "Zásady hesel se nepodařilo načíst.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Přidáno {ok} z {total} tajemství do týmové složky", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Rozšíření prohlížeče Keepiq automaticky vyplňuje vaše přihlašovací údaje, poskytuje přístupové klíče a zobrazuje kódy TOTP — a vaše tajemství přitom nikdy neopustí vaše zařízení.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Vytvoří se zástupný záznam, který zůstane prázdný, dokud jej příjemce nevyplní — nikdy si nemusíte hodnotu vymýšlet.", - "Could not reach the directory": "Adresář se nepodařilo kontaktovat" + "Could not reach the directory": "Adresář se nepodařilo kontaktovat", + "Integrations": "Integrace", + "Connection": "Připojení", + "Status message": "Zpráva o stavu", + "Last checked": "Naposledy zkontrolováno", + "All connections": "Všechna připojení", + "Add integration": "Přidat integraci", + "Open settings": "Otevřít nastavení", + "Configured": "Nastaveno", + "Limited": "Omezeno", + "Simulated": "Simulováno", + "Not available": "Není k dispozici", + "Error": "Chyba", + "e.g. Offboarding, device lost, key compromised": "např. odchod zaměstnance, ztracené zařízení, kompromitovaný klíč", + "Encryption suites": "Šifrovací sady", + "Failed to force-revoke suite": "Vynucené odvolání sady se nezdařilo", + "Failed to reinstate suite": "Obnovení sady se nezdařilo", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Vynuceně odvolat šifrovací sadu vlastněnou uživatelem nebo aplikací podle id, když to její vlastník nemůže (zapomenuté hlavní heslo, odebraný přístup nebo kompromitace), a obnovit odvolanou. Vynucené odvolání vás požádá o opětovné potvrzení vlastního hesla a trvale odstraní nouzový přístup sady.", + "Force-revoke suite": "Vynuceně odvolat sadu", + "Reinstate suite": "Obnovit sadu", + "Revoking this suite deleted %n emergency-access contact.": "Odvolání této sady odstranilo %n kontakt nouzového přístupu.", + "Revoking this suite deleted %n emergency-access contacts.": "Odvolání této sady odstranilo %n kontaktů nouzového přístupu.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Považovat tajemství sady za kompromitovaná (označit k obměně a upozornit vlastníky)" }, "plurals": null } diff --git a/l10n/da.js b/l10n/da.js index 0b9175021..1631e7d69 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Overtag som boksadministrator", "Select {name}": "Vælg {name}", "Could not load the password policy.": "Adgangskodepolitikken kunne ikke indlæses.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Tilføjede {ok} af {total} hemmeligheder til teammappen", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq-browserudvidelsen udfylder dine logins automatisk, leverer adgangsnøgler og viser TOTP-koder — uden at dine hemmeligheder nogensinde forlader din enhed.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Der oprettes en pladsholder, som forbliver tom, indtil modtageren udfylder den — du skal aldrig opdigte en værdi.", - "Could not reach the directory": "Kataloget kunne ikke nås" + "Could not reach the directory": "Kataloget kunne ikke nås", + "Integrations": "Integrationer", + "Connection": "Forbindelse", + "Status message": "Statusbesked", + "Last checked": "Sidst kontrolleret", + "All connections": "Alle forbindelser", + "Add integration": "Tilføj integration", + "Open settings": "Åbn indstillinger", + "Configured": "Konfigureret", + "Limited": "Begrænset", + "Simulated": "Simuleret", + "Not available": "Ikke tilgængelig", + "Error": "Fejl", + "e.g. Offboarding, device lost, key compromised": "f.eks. fratrædelse, mistet enhed, nøgle kompromitteret", + "Encryption suites": "Krypteringssuiter", + "Failed to force-revoke suite": "Tvangstilbagekaldelse af suite mislykkedes", + "Failed to reinstate suite": "Genindsættelse af suite mislykkedes", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Tvangstilbagekald en bruger- eller applikationsejet krypteringssuite via id, når ejeren ikke kan (en glemt hovedadgangskode, en tilbagekaldt adgang eller en kompromittering), og genindsæt en tilbagekaldt. Tvangstilbagekaldelse beder dig bekræfte din egen adgangskode igen og fjerner permanent suitens nødadgang.", + "Force-revoke suite": "Tvangstilbagekald suite", + "Reinstate suite": "Genindsæt suite", + "Revoking this suite deleted %n emergency-access contact.": "Tilbagekaldelsen af denne suite fjernede %n nødadgangskontakt.", + "Revoking this suite deleted %n emergency-access contacts.": "Tilbagekaldelsen af denne suite fjernede %n nødadgangskontakter.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Behandl suitens hemmeligheder som kompromitterede (markér til rotation, og underret ejere)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 9012c3db3..83586a9ae 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Overtag som boksadministrator", "Select {name}": "Vælg {name}", "Could not load the password policy.": "Adgangskodepolitikken kunne ikke indlæses.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Tilføjede {ok} af {total} hemmeligheder til teammappen", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq-browserudvidelsen udfylder dine logins automatisk, leverer adgangsnøgler og viser TOTP-koder — uden at dine hemmeligheder nogensinde forlader din enhed.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Der oprettes en pladsholder, som forbliver tom, indtil modtageren udfylder den — du skal aldrig opdigte en værdi.", - "Could not reach the directory": "Kataloget kunne ikke nås" + "Could not reach the directory": "Kataloget kunne ikke nås", + "Integrations": "Integrationer", + "Connection": "Forbindelse", + "Status message": "Statusbesked", + "Last checked": "Sidst kontrolleret", + "All connections": "Alle forbindelser", + "Add integration": "Tilføj integration", + "Open settings": "Åbn indstillinger", + "Configured": "Konfigureret", + "Limited": "Begrænset", + "Simulated": "Simuleret", + "Not available": "Ikke tilgængelig", + "Error": "Fejl", + "e.g. Offboarding, device lost, key compromised": "f.eks. fratrædelse, mistet enhed, nøgle kompromitteret", + "Encryption suites": "Krypteringssuiter", + "Failed to force-revoke suite": "Tvangstilbagekaldelse af suite mislykkedes", + "Failed to reinstate suite": "Genindsættelse af suite mislykkedes", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Tvangstilbagekald en bruger- eller applikationsejet krypteringssuite via id, når ejeren ikke kan (en glemt hovedadgangskode, en tilbagekaldt adgang eller en kompromittering), og genindsæt en tilbagekaldt. Tvangstilbagekaldelse beder dig bekræfte din egen adgangskode igen og fjerner permanent suitens nødadgang.", + "Force-revoke suite": "Tvangstilbagekald suite", + "Reinstate suite": "Genindsæt suite", + "Revoking this suite deleted %n emergency-access contact.": "Tilbagekaldelsen af denne suite fjernede %n nødadgangskontakt.", + "Revoking this suite deleted %n emergency-access contacts.": "Tilbagekaldelsen af denne suite fjernede %n nødadgangskontakter.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Behandl suitens hemmeligheder som kompromitterede (markér til rotation, og underret ejere)" }, "plurals": null } diff --git a/l10n/de.js b/l10n/de.js index ab0178391..6a62b148c 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Als Tresoradministrator übernehmen", "Select {name}": "{name} auswählen", "Could not load the password policy.": "Die Passwortrichtlinie konnte nicht geladen werden.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "{ok} von {total} Geheimnissen dem Teamordner hinzugefügt", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Die Keepiq-Browser-Erweiterung füllt Ihre Logins automatisch aus, stellt Passkeys bereit und zeigt TOTP-Codes an — ohne dass Ihre Geheimnisse Ihr Gerät je verlassen.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Es wird ein Platzhalter erstellt, der leer bleibt, bis der Empfänger ihn ausfüllt – Sie müssen sich niemals einen Wert ausdenken.", - "Could not reach the directory": "Das Verzeichnis konnte nicht erreicht werden" + "Could not reach the directory": "Das Verzeichnis konnte nicht erreicht werden", + "Integrations": "Integrationen", + "Connection": "Verbindung", + "Status message": "Statusmeldung", + "Last checked": "Zuletzt geprüft", + "All connections": "Alle Verbindungen", + "Add integration": "Integration hinzufügen", + "Open settings": "Einstellungen öffnen", + "Configured": "Konfiguriert", + "Limited": "Eingeschränkt", + "Simulated": "Simuliert", + "Not available": "Nicht verfügbar", + "Error": "Fehler", + "e.g. Offboarding, device lost, key compromised": "z. B. Offboarding, Gerät verloren, Schlüssel kompromittiert", + "Encryption suites": "Verschlüsselungs-Suites", + "Failed to force-revoke suite": "Zwangsweiser Widerruf der Suite fehlgeschlagen", + "Failed to reinstate suite": "Wiederherstellung der Suite fehlgeschlagen", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Eine benutzer- oder anwendungseigene Verschlüsselungs-Suite anhand der ID zwangsweise widerrufen, wenn deren Eigentümer es nicht kann (ein vergessenes Hauptpasswort, ein entzogener Zugang oder eine Kompromittierung), und eine widerrufene wiederherstellen. Der zwangsweise Widerruf verlangt die erneute Bestätigung Ihres eigenen Passworts und löscht den Notfallzugang der Suite dauerhaft.", + "Force-revoke suite": "Suite zwangsweise widerrufen", + "Reinstate suite": "Suite wiederherstellen", + "Revoking this suite deleted %n emergency-access contact.": "Der Widerruf dieser Suite hat %n Notfallzugangskontakt gelöscht.", + "Revoking this suite deleted %n emergency-access contacts.": "Der Widerruf dieser Suite hat %n Notfallzugangskontakte gelöscht.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Die Geheimnisse der Suite als kompromittiert behandeln (zur Rotation markieren und Eigentümer benachrichtigen)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index 69ce8fa38..35438c804 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Als Tresoradministrator übernehmen", "Select {name}": "{name} auswählen", "Could not load the password policy.": "Die Passwortrichtlinie konnte nicht geladen werden.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "{ok} von {total} Geheimnissen dem Teamordner hinzugefügt", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Die Keepiq-Browser-Erweiterung füllt Ihre Logins automatisch aus, stellt Passkeys bereit und zeigt TOTP-Codes an — ohne dass Ihre Geheimnisse Ihr Gerät je verlassen.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Es wird ein Platzhalter erstellt, der leer bleibt, bis der Empfänger ihn ausfüllt – Sie müssen sich niemals einen Wert ausdenken.", - "Could not reach the directory": "Das Verzeichnis konnte nicht erreicht werden" + "Could not reach the directory": "Das Verzeichnis konnte nicht erreicht werden", + "Integrations": "Integrationen", + "Connection": "Verbindung", + "Status message": "Statusmeldung", + "Last checked": "Zuletzt geprüft", + "All connections": "Alle Verbindungen", + "Add integration": "Integration hinzufügen", + "Open settings": "Einstellungen öffnen", + "Configured": "Konfiguriert", + "Limited": "Eingeschränkt", + "Simulated": "Simuliert", + "Not available": "Nicht verfügbar", + "Error": "Fehler", + "e.g. Offboarding, device lost, key compromised": "z. B. Offboarding, Gerät verloren, Schlüssel kompromittiert", + "Encryption suites": "Verschlüsselungs-Suites", + "Failed to force-revoke suite": "Zwangsweiser Widerruf der Suite fehlgeschlagen", + "Failed to reinstate suite": "Wiederherstellung der Suite fehlgeschlagen", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Eine benutzer- oder anwendungseigene Verschlüsselungs-Suite anhand der ID zwangsweise widerrufen, wenn deren Eigentümer es nicht kann (ein vergessenes Hauptpasswort, ein entzogener Zugang oder eine Kompromittierung), und eine widerrufene wiederherstellen. Der zwangsweise Widerruf verlangt die erneute Bestätigung Ihres eigenen Passworts und löscht den Notfallzugang der Suite dauerhaft.", + "Force-revoke suite": "Suite zwangsweise widerrufen", + "Reinstate suite": "Suite wiederherstellen", + "Revoking this suite deleted %n emergency-access contact.": "Der Widerruf dieser Suite hat %n Notfallzugangskontakt gelöscht.", + "Revoking this suite deleted %n emergency-access contacts.": "Der Widerruf dieser Suite hat %n Notfallzugangskontakte gelöscht.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Die Geheimnisse der Suite als kompromittiert behandeln (zur Rotation markieren und Eigentümer benachrichtigen)" }, "plurals": null } diff --git a/l10n/el.js b/l10n/el.js index 21934b32c..b8c9ab28a 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Ανάληψη ως διαχειριστής θησαυροφυλακίου", "Select {name}": "Επιλογή {name}", "Could not load the password policy.": "Δεν ήταν δυνατή η φόρτωση της πολιτικής κωδικών πρόσβασης.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Προστέθηκαν {ok} από {total} μυστικά στον φάκελο ομάδας", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Η επέκταση Keepiq για το πρόγραμμα περιήγησης συμπληρώνει αυτόματα τις συνδέσεις σας, παρέχει κλειδιά πρόσβασης και εμφανίζει κωδικούς TOTP — χωρίς τα μυστικά σας να φεύγουν ποτέ από τη συσκευή σας.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Δημιουργείται ένα σύμβολο κράτησης που παραμένει κενό μέχρι να το συμπληρώσει ο παραλήπτης — δεν χρειάζεται ποτέ να επινοήσετε τιμή.", - "Could not reach the directory": "Δεν ήταν δυνατή η σύνδεση με τον κατάλογο" + "Could not reach the directory": "Δεν ήταν δυνατή η σύνδεση με τον κατάλογο", + "Integrations": "Ενσωματώσεις", + "Connection": "Σύνδεση", + "Status message": "Μήνυμα κατάστασης", + "Last checked": "Τελευταίος έλεγχος", + "All connections": "Όλες οι συνδέσεις", + "Add integration": "Προσθήκη ενσωμάτωσης", + "Open settings": "Άνοιγμα ρυθμίσεων", + "Configured": "Διαμορφώθηκε", + "Limited": "Περιορισμένη", + "Simulated": "Προσομοιωμένη", + "Not available": "Μη διαθέσιμη", + "Error": "Σφάλμα", + "e.g. Offboarding, device lost, key compromised": "π.χ. αποχώρηση, απώλεια συσκευής, παραβίαση κλειδιού", + "Encryption suites": "Σουίτες κρυπτογράφησης", + "Failed to force-revoke suite": "Η αναγκαστική ανάκληση της σουίτας απέτυχε", + "Failed to reinstate suite": "Η επαναφορά της σουίτας απέτυχε", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Αναγκαστική ανάκληση μιας σουίτας κρυπτογράφησης που ανήκει σε χρήστη ή εφαρμογή βάσει id όταν ο ιδιοκτήτης της δεν μπορεί (ξεχασμένος κύριος κωδικός, ανακληθείσα πρόσβαση ή παραβίαση), και επαναφορά μιας ανακληθείσας. Η αναγκαστική ανάκληση σας ζητά να επιβεβαιώσετε ξανά τον δικό σας κωδικό και διαγράφει οριστικά την πρόσβαση έκτακτης ανάγκης της σουίτας.", + "Force-revoke suite": "Αναγκαστική ανάκληση σουίτας", + "Reinstate suite": "Επαναφορά σουίτας", + "Revoking this suite deleted %n emergency-access contact.": "Η ανάκληση αυτής της σουίτας διέγραψε %n επαφή πρόσβασης έκτακτης ανάγκης.", + "Revoking this suite deleted %n emergency-access contacts.": "Η ανάκληση αυτής της σουίτας διέγραψε %n επαφές πρόσβασης έκτακτης ανάγκης.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Αντιμετώπιση των μυστικών της σουίτας ως παραβιασμένων (επισήμανση για εναλλαγή και ειδοποίηση ιδιοκτητών)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index f20cdbedc..e21945ad3 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Ανάληψη ως διαχειριστής θησαυροφυλακίου", "Select {name}": "Επιλογή {name}", "Could not load the password policy.": "Δεν ήταν δυνατή η φόρτωση της πολιτικής κωδικών πρόσβασης.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Προστέθηκαν {ok} από {total} μυστικά στον φάκελο ομάδας", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Η επέκταση Keepiq για το πρόγραμμα περιήγησης συμπληρώνει αυτόματα τις συνδέσεις σας, παρέχει κλειδιά πρόσβασης και εμφανίζει κωδικούς TOTP — χωρίς τα μυστικά σας να φεύγουν ποτέ από τη συσκευή σας.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Δημιουργείται ένα σύμβολο κράτησης που παραμένει κενό μέχρι να το συμπληρώσει ο παραλήπτης — δεν χρειάζεται ποτέ να επινοήσετε τιμή.", - "Could not reach the directory": "Δεν ήταν δυνατή η σύνδεση με τον κατάλογο" + "Could not reach the directory": "Δεν ήταν δυνατή η σύνδεση με τον κατάλογο", + "Integrations": "Ενσωματώσεις", + "Connection": "Σύνδεση", + "Status message": "Μήνυμα κατάστασης", + "Last checked": "Τελευταίος έλεγχος", + "All connections": "Όλες οι συνδέσεις", + "Add integration": "Προσθήκη ενσωμάτωσης", + "Open settings": "Άνοιγμα ρυθμίσεων", + "Configured": "Διαμορφώθηκε", + "Limited": "Περιορισμένη", + "Simulated": "Προσομοιωμένη", + "Not available": "Μη διαθέσιμη", + "Error": "Σφάλμα", + "e.g. Offboarding, device lost, key compromised": "π.χ. αποχώρηση, απώλεια συσκευής, παραβίαση κλειδιού", + "Encryption suites": "Σουίτες κρυπτογράφησης", + "Failed to force-revoke suite": "Η αναγκαστική ανάκληση της σουίτας απέτυχε", + "Failed to reinstate suite": "Η επαναφορά της σουίτας απέτυχε", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Αναγκαστική ανάκληση μιας σουίτας κρυπτογράφησης που ανήκει σε χρήστη ή εφαρμογή βάσει id όταν ο ιδιοκτήτης της δεν μπορεί (ξεχασμένος κύριος κωδικός, ανακληθείσα πρόσβαση ή παραβίαση), και επαναφορά μιας ανακληθείσας. Η αναγκαστική ανάκληση σας ζητά να επιβεβαιώσετε ξανά τον δικό σας κωδικό και διαγράφει οριστικά την πρόσβαση έκτακτης ανάγκης της σουίτας.", + "Force-revoke suite": "Αναγκαστική ανάκληση σουίτας", + "Reinstate suite": "Επαναφορά σουίτας", + "Revoking this suite deleted %n emergency-access contact.": "Η ανάκληση αυτής της σουίτας διέγραψε %n επαφή πρόσβασης έκτακτης ανάγκης.", + "Revoking this suite deleted %n emergency-access contacts.": "Η ανάκληση αυτής της σουίτας διέγραψε %n επαφές πρόσβασης έκτακτης ανάγκης.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Αντιμετώπιση των μυστικών της σουίτας ως παραβιασμένων (επισήμανση για εναλλαγή και ειδοποίηση ιδιοκτητών)" }, "plurals": null } diff --git a/l10n/en.js b/l10n/en.js index 5c2ad61bc..818796d25 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1,6 +1,14 @@ OC.L10N.register( "keepiq", { + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Take over as vault administrator", "Select {name}": "Select {name}", "Could not load the password policy.": "Could not load the password policy.", @@ -1133,7 +1141,34 @@ OC.L10N.register( "Shared {ok} of {total} secrets": "Shared {ok} of {total} secrets", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.", - "Could not reach the directory": "Could not reach the directory" + "Could not reach the directory": "Could not reach the directory", + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Integrations": "Integrations", + "Connection": "Connection", + "Status message": "Status message", + "Last checked": "Last checked", + "All connections": "All connections", + "Add integration": "Add integration", + "Open settings": "Open settings", + "Configured": "Configured", + "Limited": "Limited", + "Simulated": "Simulated", + "Not available": "Not available", + "Error": "Error", + "e.g. Offboarding, device lost, key compromised": "e.g. Offboarding, device lost, key compromised", + "Encryption suites": "Encryption suites", + "Failed to force-revoke suite": "Failed to force-revoke suite", + "Failed to reinstate suite": "Failed to reinstate suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.", + "Force-revoke suite": "Force-revoke suite", + "Reinstate suite": "Reinstate suite", + "Revoking this suite deleted %n emergency-access contact.": "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.": "Revoking this suite deleted %n emergency-access contacts.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Treat the suite's secrets as compromised (flag for rotation and notify owners)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index ceaebde6d..dca1a7cfb 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1,5 +1,13 @@ { "translations": { + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Take over as vault administrator", "Select {name}": "Select {name}", "Could not load the password policy.": "Could not load the password policy.", @@ -1132,7 +1140,34 @@ "Shared {ok} of {total} secrets": "Shared {ok} of {total} secrets", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.", - "Could not reach the directory": "Could not reach the directory" + "Could not reach the directory": "Could not reach the directory", + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Integrations": "Integrations", + "Connection": "Connection", + "Status message": "Status message", + "Last checked": "Last checked", + "All connections": "All connections", + "Add integration": "Add integration", + "Open settings": "Open settings", + "Configured": "Configured", + "Limited": "Limited", + "Simulated": "Simulated", + "Not available": "Not available", + "Error": "Error", + "e.g. Offboarding, device lost, key compromised": "e.g. Offboarding, device lost, key compromised", + "Encryption suites": "Encryption suites", + "Failed to force-revoke suite": "Failed to force-revoke suite", + "Failed to reinstate suite": "Failed to reinstate suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.", + "Force-revoke suite": "Force-revoke suite", + "Reinstate suite": "Reinstate suite", + "Revoking this suite deleted %n emergency-access contact.": "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.": "Revoking this suite deleted %n emergency-access contacts.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Treat the suite's secrets as compromised (flag for rotation and notify owners)" }, "plurals": "", "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/es.js b/l10n/es.js index a350f7420..e4e0ed139 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Tomar el control como administrador de la caja fuerte", "Select {name}": "Seleccionar {name}", "Could not load the password policy.": "No se pudo cargar la política de contraseñas.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Se han añadido {ok} de {total} secretos a la carpeta de equipo", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "La extensión de navegador de Keepiq rellena automáticamente sus inicios de sesión, proporciona claves de acceso y muestra códigos TOTP, sin que sus secretos salgan nunca de su dispositivo.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Se crea un marcador que permanece vacío hasta que el destinatario lo rellene: nunca tienes que inventar un valor.", - "Could not reach the directory": "No se pudo conectar con el directorio" + "Could not reach the directory": "No se pudo conectar con el directorio", + "Integrations": "Integraciones", + "Connection": "Conexión", + "Status message": "Mensaje de estado", + "Last checked": "Última comprobación", + "All connections": "Todas las conexiones", + "Add integration": "Añadir integración", + "Open settings": "Abrir ajustes", + "Configured": "Configurado", + "Limited": "Limitado", + "Simulated": "Simulado", + "Not available": "No disponible", + "Error": "Error", + "e.g. Offboarding, device lost, key compromised": "p. ej. baja del empleado, dispositivo perdido, clave comprometida", + "Encryption suites": "Suites de cifrado", + "Failed to force-revoke suite": "No se pudo revocar la suite a la fuerza", + "Failed to reinstate suite": "No se pudo restablecer la suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revocar a la fuerza una suite de cifrado propiedad de un usuario o aplicación por su id cuando su propietario no puede (una contraseña maestra olvidada, una baja no autorizada o un compromiso), y restablecer una revocada. La revocación forzada le pide volver a confirmar su propia contraseña y elimina permanentemente el acceso de emergencia de la suite.", + "Force-revoke suite": "Revocar la suite a la fuerza", + "Reinstate suite": "Restablecer la suite", + "Revoking this suite deleted %n emergency-access contact.": "Revocar esta suite eliminó %n contacto de acceso de emergencia.", + "Revoking this suite deleted %n emergency-access contacts.": "Revocar esta suite eliminó %n contactos de acceso de emergencia.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tratar los secretos de la suite como comprometidos (marcar para rotación y notificar a los propietarios)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index d33006268..a2e9f8c5b 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Tomar el control como administrador de la caja fuerte", "Select {name}": "Seleccionar {name}", "Could not load the password policy.": "No se pudo cargar la política de contraseñas.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Se han añadido {ok} de {total} secretos a la carpeta de equipo", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "La extensión de navegador de Keepiq rellena automáticamente sus inicios de sesión, proporciona claves de acceso y muestra códigos TOTP, sin que sus secretos salgan nunca de su dispositivo.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Se crea un marcador que permanece vacío hasta que el destinatario lo rellene: nunca tienes que inventar un valor.", - "Could not reach the directory": "No se pudo conectar con el directorio" + "Could not reach the directory": "No se pudo conectar con el directorio", + "Integrations": "Integraciones", + "Connection": "Conexión", + "Status message": "Mensaje de estado", + "Last checked": "Última comprobación", + "All connections": "Todas las conexiones", + "Add integration": "Añadir integración", + "Open settings": "Abrir ajustes", + "Configured": "Configurado", + "Limited": "Limitado", + "Simulated": "Simulado", + "Not available": "No disponible", + "Error": "Error", + "e.g. Offboarding, device lost, key compromised": "p. ej. baja del empleado, dispositivo perdido, clave comprometida", + "Encryption suites": "Suites de cifrado", + "Failed to force-revoke suite": "No se pudo revocar la suite a la fuerza", + "Failed to reinstate suite": "No se pudo restablecer la suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revocar a la fuerza una suite de cifrado propiedad de un usuario o aplicación por su id cuando su propietario no puede (una contraseña maestra olvidada, una baja no autorizada o un compromiso), y restablecer una revocada. La revocación forzada le pide volver a confirmar su propia contraseña y elimina permanentemente el acceso de emergencia de la suite.", + "Force-revoke suite": "Revocar la suite a la fuerza", + "Reinstate suite": "Restablecer la suite", + "Revoking this suite deleted %n emergency-access contact.": "Revocar esta suite eliminó %n contacto de acceso de emergencia.", + "Revoking this suite deleted %n emergency-access contacts.": "Revocar esta suite eliminó %n contactos de acceso de emergencia.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tratar los secretos de la suite como comprometidos (marcar para rotación y notificar a los propietarios)" }, "plurals": null } diff --git a/l10n/et.js b/l10n/et.js index fc38a533e..cc6084153 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Võta hoidla administraatorina üle", "Select {name}": "Vali {name}", "Could not load the password policy.": "Paroolipoliitika laadimine ebaõnnestus.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Meeskonnakausta lisatud {ok} / {total} saladust", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiqi brauserilaiendus täidab sinu sisselogimisandmed automaatselt, pakub pääsuvõtmeid ja näitab TOTP-koode — ilma et sinu saladused kunagi sinu seadmest lahkuksid.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Luuakse kohahoidja, mis jääb tühjaks kuni saaja selle täidab — sul ei ole kunagi vaja väärtust välja mõelda.", - "Could not reach the directory": "Kataloogiga ei õnnestunud ühendust saada" + "Could not reach the directory": "Kataloogiga ei õnnestunud ühendust saada", + "Integrations": "Integratsioonid", + "Connection": "Ühendus", + "Status message": "Olekuteade", + "Last checked": "Viimati kontrollitud", + "All connections": "Kõik ühendused", + "Add integration": "Lisa integratsioon", + "Open settings": "Ava sätted", + "Configured": "Konfigureeritud", + "Limited": "Piiratud", + "Simulated": "Simuleeritud", + "Not available": "Pole saadaval", + "Error": "Viga", + "e.g. Offboarding, device lost, key compromised": "nt töösuhte lõpp, kaotatud seade, võti ohustatud", + "Encryption suites": "Krüpteerimiskomplektid", + "Failed to force-revoke suite": "Komplekti sunniviisiline tühistamine ebaõnnestus", + "Failed to reinstate suite": "Komplekti taastamine ebaõnnestus", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Tühista sunniviisiliselt kasutajale või rakendusele kuuluv krüpteerimiskomplekt id alusel, kui selle omanik ei saa (unustatud ülemparool, tühistatud juurdepääs või ohustatus), ja taasta tühistatud. Sunniviisiline tühistamine palub sul oma parool uuesti kinnitada ja kustutab jäädavalt komplekti hädajuurdepääsu.", + "Force-revoke suite": "Tühista komplekt sunniviisiliselt", + "Reinstate suite": "Taasta komplekt", + "Revoking this suite deleted %n emergency-access contact.": "Selle komplekti tühistamine kustutas %n hädajuurdepääsu kontakti.", + "Revoking this suite deleted %n emergency-access contacts.": "Selle komplekti tühistamine kustutas %n hädajuurdepääsu kontakti.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Käsitle komplekti saladusi ohustatuna (märgi rotatsiooniks ja teavita omanikke)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 9c36d4ae1..8db99eafc 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Võta hoidla administraatorina üle", "Select {name}": "Vali {name}", "Could not load the password policy.": "Paroolipoliitika laadimine ebaõnnestus.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Meeskonnakausta lisatud {ok} / {total} saladust", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiqi brauserilaiendus täidab sinu sisselogimisandmed automaatselt, pakub pääsuvõtmeid ja näitab TOTP-koode — ilma et sinu saladused kunagi sinu seadmest lahkuksid.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Luuakse kohahoidja, mis jääb tühjaks kuni saaja selle täidab — sul ei ole kunagi vaja väärtust välja mõelda.", - "Could not reach the directory": "Kataloogiga ei õnnestunud ühendust saada" + "Could not reach the directory": "Kataloogiga ei õnnestunud ühendust saada", + "Integrations": "Integratsioonid", + "Connection": "Ühendus", + "Status message": "Olekuteade", + "Last checked": "Viimati kontrollitud", + "All connections": "Kõik ühendused", + "Add integration": "Lisa integratsioon", + "Open settings": "Ava sätted", + "Configured": "Konfigureeritud", + "Limited": "Piiratud", + "Simulated": "Simuleeritud", + "Not available": "Pole saadaval", + "Error": "Viga", + "e.g. Offboarding, device lost, key compromised": "nt töösuhte lõpp, kaotatud seade, võti ohustatud", + "Encryption suites": "Krüpteerimiskomplektid", + "Failed to force-revoke suite": "Komplekti sunniviisiline tühistamine ebaõnnestus", + "Failed to reinstate suite": "Komplekti taastamine ebaõnnestus", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Tühista sunniviisiliselt kasutajale või rakendusele kuuluv krüpteerimiskomplekt id alusel, kui selle omanik ei saa (unustatud ülemparool, tühistatud juurdepääs või ohustatus), ja taasta tühistatud. Sunniviisiline tühistamine palub sul oma parool uuesti kinnitada ja kustutab jäädavalt komplekti hädajuurdepääsu.", + "Force-revoke suite": "Tühista komplekt sunniviisiliselt", + "Reinstate suite": "Taasta komplekt", + "Revoking this suite deleted %n emergency-access contact.": "Selle komplekti tühistamine kustutas %n hädajuurdepääsu kontakti.", + "Revoking this suite deleted %n emergency-access contacts.": "Selle komplekti tühistamine kustutas %n hädajuurdepääsu kontakti.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Käsitle komplekti saladusi ohustatuna (märgi rotatsiooniks ja teavita omanikke)" }, "plurals": null } diff --git a/l10n/fi.js b/l10n/fi.js index 4b25bf03b..466319642 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Ota haltuun holvin ylläpitäjänä", "Select {name}": "Valitse {name}", "Could not load the password policy.": "Salasanakäytäntöä ei voitu ladata.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Lisätty tiimikansioon {ok} / {total} salaisuutta", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiqin selainlaajennus täyttää kirjautumistietosi automaattisesti, tarjoaa pääsyavaimia ja näyttää TOTP-koodeja — eivätkä salaisuutesi koskaan poistu laitteeltasi.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Luodaan paikanvaraaja, joka pysyy tyhjänä kunnes vastaanottaja täyttää sen — sinun ei tarvitse koskaan keksiä arvoa.", - "Could not reach the directory": "Hakemistoon ei voitu yhdistää" + "Could not reach the directory": "Hakemistoon ei voitu yhdistää", + "Integrations": "Integraatiot", + "Connection": "Yhteys", + "Status message": "Tilaviesti", + "Last checked": "Viimeksi tarkistettu", + "All connections": "Kaikki yhteydet", + "Add integration": "Lisää integraatio", + "Open settings": "Avaa asetukset", + "Configured": "Määritetty", + "Limited": "Rajoitettu", + "Simulated": "Simuloitu", + "Not available": "Ei saatavilla", + "Error": "Virhe", + "e.g. Offboarding, device lost, key compromised": "esim. työsuhteen päättyminen, kadonnut laite, avain vaarantunut", + "Encryption suites": "Salaussarjat", + "Failed to force-revoke suite": "Sarjan pakotettu peruutus epäonnistui", + "Failed to reinstate suite": "Sarjan palautus epäonnistui", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Peruuta pakotetusti käyttäjän tai sovelluksen omistama salaussarja tunnisteen perusteella, kun sen omistaja ei voi (unohtunut pääsalasana, peruutettu käyttöoikeus tai vaarantuminen), ja palauta peruutettu. Pakotettu peruutus pyytää vahvistamaan oman salasanasi uudelleen ja poistaa sarjan hätäkäytön pysyvästi.", + "Force-revoke suite": "Peruuta sarja pakotetusti", + "Reinstate suite": "Palauta sarja", + "Revoking this suite deleted %n emergency-access contact.": "Tämän sarjan peruuttaminen poisti %n hätäkäytön yhteyshenkilön.", + "Revoking this suite deleted %n emergency-access contacts.": "Tämän sarjan peruuttaminen poisti %n hätäkäytön yhteyshenkilöä.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Käsittele sarjan salaisuuksia vaarantuneina (merkitse kierrätettäväksi ja ilmoita omistajille)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index bce62b4eb..19c45dc59 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Ota haltuun holvin ylläpitäjänä", "Select {name}": "Valitse {name}", "Could not load the password policy.": "Salasanakäytäntöä ei voitu ladata.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Lisätty tiimikansioon {ok} / {total} salaisuutta", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiqin selainlaajennus täyttää kirjautumistietosi automaattisesti, tarjoaa pääsyavaimia ja näyttää TOTP-koodeja — eivätkä salaisuutesi koskaan poistu laitteeltasi.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Luodaan paikanvaraaja, joka pysyy tyhjänä kunnes vastaanottaja täyttää sen — sinun ei tarvitse koskaan keksiä arvoa.", - "Could not reach the directory": "Hakemistoon ei voitu yhdistää" + "Could not reach the directory": "Hakemistoon ei voitu yhdistää", + "Integrations": "Integraatiot", + "Connection": "Yhteys", + "Status message": "Tilaviesti", + "Last checked": "Viimeksi tarkistettu", + "All connections": "Kaikki yhteydet", + "Add integration": "Lisää integraatio", + "Open settings": "Avaa asetukset", + "Configured": "Määritetty", + "Limited": "Rajoitettu", + "Simulated": "Simuloitu", + "Not available": "Ei saatavilla", + "Error": "Virhe", + "e.g. Offboarding, device lost, key compromised": "esim. työsuhteen päättyminen, kadonnut laite, avain vaarantunut", + "Encryption suites": "Salaussarjat", + "Failed to force-revoke suite": "Sarjan pakotettu peruutus epäonnistui", + "Failed to reinstate suite": "Sarjan palautus epäonnistui", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Peruuta pakotetusti käyttäjän tai sovelluksen omistama salaussarja tunnisteen perusteella, kun sen omistaja ei voi (unohtunut pääsalasana, peruutettu käyttöoikeus tai vaarantuminen), ja palauta peruutettu. Pakotettu peruutus pyytää vahvistamaan oman salasanasi uudelleen ja poistaa sarjan hätäkäytön pysyvästi.", + "Force-revoke suite": "Peruuta sarja pakotetusti", + "Reinstate suite": "Palauta sarja", + "Revoking this suite deleted %n emergency-access contact.": "Tämän sarjan peruuttaminen poisti %n hätäkäytön yhteyshenkilön.", + "Revoking this suite deleted %n emergency-access contacts.": "Tämän sarjan peruuttaminen poisti %n hätäkäytön yhteyshenkilöä.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Käsittele sarjan salaisuuksia vaarantuneina (merkitse kierrätettäväksi ja ilmoita omistajille)" }, "plurals": null } diff --git a/l10n/fr.js b/l10n/fr.js index 7b7e9fb6f..eb4bb35a3 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Reprendre en tant qu'administrateur du coffre", "Select {name}": "Sélectionner {name}", "Could not load the password policy.": "Impossible de charger la politique de mots de passe.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "{ok} secret(s) sur {total} ajouté(s) au dossier d'équipe", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "L'extension de navigateur Keepiq remplit automatiquement vos identifiants, fournit des clés d'accès et affiche les codes TOTP — sans que vos secrets quittent jamais votre appareil.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Un espace réservé est créé et reste vide jusqu’à ce que le destinataire le remplisse — vous n’avez jamais à inventer de valeur.", - "Could not reach the directory": "Impossible de joindre l'annuaire" + "Could not reach the directory": "Impossible de joindre l'annuaire", + "Integrations": "Intégrations", + "Connection": "Connexion", + "Status message": "Message d'état", + "Last checked": "Dernière vérification", + "All connections": "Toutes les connexions", + "Add integration": "Ajouter une intégration", + "Open settings": "Ouvrir les paramètres", + "Configured": "Configuré", + "Limited": "Limité", + "Simulated": "Simulé", + "Not available": "Non disponible", + "Error": "Erreur", + "e.g. Offboarding, device lost, key compromised": "p. ex. départ d'un employé, appareil perdu, clé compromise", + "Encryption suites": "Suites de chiffrement", + "Failed to force-revoke suite": "Échec de la révocation forcée de la suite", + "Failed to reinstate suite": "Échec du rétablissement de la suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Révoquer de force une suite de chiffrement appartenant à un utilisateur ou à une application par son id lorsque son propriétaire ne le peut pas (un mot de passe maître oublié, un départ non autorisé ou une compromission), et rétablir une suite révoquée. La révocation forcée vous demande de confirmer à nouveau votre propre mot de passe et supprime définitivement l'accès d'urgence de la suite.", + "Force-revoke suite": "Révoquer la suite de force", + "Reinstate suite": "Rétablir la suite", + "Revoking this suite deleted %n emergency-access contact.": "La révocation de cette suite a supprimé %n contact d'accès d'urgence.", + "Revoking this suite deleted %n emergency-access contacts.": "La révocation de cette suite a supprimé %n contacts d'accès d'urgence.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Traiter les secrets de la suite comme compromis (marquer pour rotation et avertir les propriétaires)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 6a43c16b3..fda027cf7 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Reprendre en tant qu'administrateur du coffre", "Select {name}": "Sélectionner {name}", "Could not load the password policy.": "Impossible de charger la politique de mots de passe.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "{ok} secret(s) sur {total} ajouté(s) au dossier d'équipe", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "L'extension de navigateur Keepiq remplit automatiquement vos identifiants, fournit des clés d'accès et affiche les codes TOTP — sans que vos secrets quittent jamais votre appareil.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Un espace réservé est créé et reste vide jusqu’à ce que le destinataire le remplisse — vous n’avez jamais à inventer de valeur.", - "Could not reach the directory": "Impossible de joindre l'annuaire" + "Could not reach the directory": "Impossible de joindre l'annuaire", + "Integrations": "Intégrations", + "Connection": "Connexion", + "Status message": "Message d'état", + "Last checked": "Dernière vérification", + "All connections": "Toutes les connexions", + "Add integration": "Ajouter une intégration", + "Open settings": "Ouvrir les paramètres", + "Configured": "Configuré", + "Limited": "Limité", + "Simulated": "Simulé", + "Not available": "Non disponible", + "Error": "Erreur", + "e.g. Offboarding, device lost, key compromised": "p. ex. départ d'un employé, appareil perdu, clé compromise", + "Encryption suites": "Suites de chiffrement", + "Failed to force-revoke suite": "Échec de la révocation forcée de la suite", + "Failed to reinstate suite": "Échec du rétablissement de la suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Révoquer de force une suite de chiffrement appartenant à un utilisateur ou à une application par son id lorsque son propriétaire ne le peut pas (un mot de passe maître oublié, un départ non autorisé ou une compromission), et rétablir une suite révoquée. La révocation forcée vous demande de confirmer à nouveau votre propre mot de passe et supprime définitivement l'accès d'urgence de la suite.", + "Force-revoke suite": "Révoquer la suite de force", + "Reinstate suite": "Rétablir la suite", + "Revoking this suite deleted %n emergency-access contact.": "La révocation de cette suite a supprimé %n contact d'accès d'urgence.", + "Revoking this suite deleted %n emergency-access contacts.": "La révocation de cette suite a supprimé %n contacts d'accès d'urgence.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Traiter les secrets de la suite comme compromis (marquer pour rotation et avertir les propriétaires)" }, "plurals": null } diff --git a/l10n/ga.js b/l10n/ga.js index 26daf7f1d..49be08e68 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Glac ceannas mar riarthóir na daingine", "Select {name}": "Roghnaigh {name}", "Could not load the password policy.": "Níorbh fhéidir an polasaí pasfhocail a lódáil.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Cuireadh {ok} as {total} rún leis an bhfillteán foirne", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Líonann breiseán brabhsálaí Keepiq do chuid logálacha isteach go huathoibríoch, soláthraíonn sé eochracha rochtana, agus taispeánann sé cóid TOTP — agus ní fhágann do rúin do ghléas riamh.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Cruthaítear coimeádán a fhanann folamh go dtí go líonann an faighteoir é — ní gá duit luach a cheapadh riamh.", - "Could not reach the directory": "Níorbh fhéidir teacht ar an eolaire" + "Could not reach the directory": "Níorbh fhéidir teacht ar an eolaire", + "Integrations": "Comhtháthuithe", + "Connection": "Nasc", + "Status message": "Teachtaireacht stádais", + "Last checked": "Seiceáladh go deireanach", + "All connections": "Gach nasc", + "Add integration": "Cuir comhtháthú leis", + "Open settings": "Oscail na socruithe", + "Configured": "Cumraithe", + "Limited": "Teoranta", + "Simulated": "Insamhlaithe", + "Not available": "Níl sé ar fáil", + "Error": "Earráid", + "e.g. Offboarding, device lost, key compromised": "e.g. Offboarding, device lost, key compromised", + "Encryption suites": "Encryption suites", + "Failed to force-revoke suite": "Failed to force-revoke suite", + "Failed to reinstate suite": "Failed to reinstate suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.", + "Force-revoke suite": "Force-revoke suite", + "Reinstate suite": "Reinstate suite", + "Revoking this suite deleted %n emergency-access contact.": "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.": "Revoking this suite deleted %n emergency-access contacts.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Treat the suite's secrets as compromised (flag for rotation and notify owners)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ga.json b/l10n/ga.json index 6c306d37f..843f8d405 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Glac ceannas mar riarthóir na daingine", "Select {name}": "Roghnaigh {name}", "Could not load the password policy.": "Níorbh fhéidir an polasaí pasfhocail a lódáil.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Cuireadh {ok} as {total} rún leis an bhfillteán foirne", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Líonann breiseán brabhsálaí Keepiq do chuid logálacha isteach go huathoibríoch, soláthraíonn sé eochracha rochtana, agus taispeánann sé cóid TOTP — agus ní fhágann do rúin do ghléas riamh.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Cruthaítear coimeádán a fhanann folamh go dtí go líonann an faighteoir é — ní gá duit luach a cheapadh riamh.", - "Could not reach the directory": "Níorbh fhéidir teacht ar an eolaire" + "Could not reach the directory": "Níorbh fhéidir teacht ar an eolaire", + "Integrations": "Comhtháthuithe", + "Connection": "Nasc", + "Status message": "Teachtaireacht stádais", + "Last checked": "Seiceáladh go deireanach", + "All connections": "Gach nasc", + "Add integration": "Cuir comhtháthú leis", + "Open settings": "Oscail na socruithe", + "Configured": "Cumraithe", + "Limited": "Teoranta", + "Simulated": "Insamhlaithe", + "Not available": "Níl sé ar fáil", + "Error": "Earráid", + "e.g. Offboarding, device lost, key compromised": "e.g. Offboarding, device lost, key compromised", + "Encryption suites": "Encryption suites", + "Failed to force-revoke suite": "Failed to force-revoke suite", + "Failed to reinstate suite": "Failed to reinstate suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.", + "Force-revoke suite": "Force-revoke suite", + "Reinstate suite": "Reinstate suite", + "Revoking this suite deleted %n emergency-access contact.": "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.": "Revoking this suite deleted %n emergency-access contacts.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Treat the suite's secrets as compromised (flag for rotation and notify owners)" }, "plurals": null } diff --git a/l10n/hr.js b/l10n/hr.js index 7757ddd55..44b2bdfb9 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Preuzmi kao administrator trezora", "Select {name}": "Odaberi {name}", "Could not load the password policy.": "Pravila lozinki nije moguće učitati.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Dodano {ok} od {total} tajni u timsku mapu", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Proširenje preglednika Keepiq automatski ispunjava vaše prijave, pruža pristupne ključeve i prikazuje TOTP kodove — a vaše tajne pritom nikada ne izlaze s vašeg uređaja.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Stvara se rezervirano mjesto koje ostaje prazno dok ga primatelj ne ispuni — nikada ne morate izmišljati vrijednost.", - "Could not reach the directory": "Do imenika nije bilo moguće doći" + "Could not reach the directory": "Do imenika nije bilo moguće doći", + "Integrations": "Integracije", + "Connection": "Veza", + "Status message": "Poruka o statusu", + "Last checked": "Posljednja provjera", + "All connections": "Sve veze", + "Add integration": "Dodaj integraciju", + "Open settings": "Otvori postavke", + "Configured": "Konfigurirano", + "Limited": "Ograničeno", + "Simulated": "Simulirano", + "Not available": "Nije dostupno", + "Error": "Pogreška", + "e.g. Offboarding, device lost, key compromised": "npr. odlazak zaposlenika, izgubljeni uređaj, ključ kompromitiran", + "Encryption suites": "Kompleti šifriranja", + "Failed to force-revoke suite": "Prisilno opozivanje kompleta nije uspjelo", + "Failed to reinstate suite": "Vraćanje kompleta nije uspjelo", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Prisilno opozovi komplet šifriranja u vlasništvu korisnika ili aplikacije prema id-u kada njegov vlasnik to ne može (zaboravljena glavna lozinka, opozvani pristup ili kompromitacija) i vrati opozvani. Prisilno opozivanje traži da ponovno potvrdite vlastitu lozinku i trajno briše hitni pristup kompleta.", + "Force-revoke suite": "Prisilno opozovi komplet", + "Reinstate suite": "Vrati komplet", + "Revoking this suite deleted %n emergency-access contact.": "Opozivanje ovog kompleta izbrisalo je %n kontakt hitnog pristupa.", + "Revoking this suite deleted %n emergency-access contacts.": "Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tretiraj tajne kompleta kao kompromitirane (označi za rotaciju i obavijesti vlasnike)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hr.json b/l10n/hr.json index f41ee0fe3..56a49fe97 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Preuzmi kao administrator trezora", "Select {name}": "Odaberi {name}", "Could not load the password policy.": "Pravila lozinki nije moguće učitati.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Dodano {ok} od {total} tajni u timsku mapu", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Proširenje preglednika Keepiq automatski ispunjava vaše prijave, pruža pristupne ključeve i prikazuje TOTP kodove — a vaše tajne pritom nikada ne izlaze s vašeg uređaja.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Stvara se rezervirano mjesto koje ostaje prazno dok ga primatelj ne ispuni — nikada ne morate izmišljati vrijednost.", - "Could not reach the directory": "Do imenika nije bilo moguće doći" + "Could not reach the directory": "Do imenika nije bilo moguće doći", + "Integrations": "Integracije", + "Connection": "Veza", + "Status message": "Poruka o statusu", + "Last checked": "Posljednja provjera", + "All connections": "Sve veze", + "Add integration": "Dodaj integraciju", + "Open settings": "Otvori postavke", + "Configured": "Konfigurirano", + "Limited": "Ograničeno", + "Simulated": "Simulirano", + "Not available": "Nije dostupno", + "Error": "Pogreška", + "e.g. Offboarding, device lost, key compromised": "npr. odlazak zaposlenika, izgubljeni uređaj, ključ kompromitiran", + "Encryption suites": "Kompleti šifriranja", + "Failed to force-revoke suite": "Prisilno opozivanje kompleta nije uspjelo", + "Failed to reinstate suite": "Vraćanje kompleta nije uspjelo", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Prisilno opozovi komplet šifriranja u vlasništvu korisnika ili aplikacije prema id-u kada njegov vlasnik to ne može (zaboravljena glavna lozinka, opozvani pristup ili kompromitacija) i vrati opozvani. Prisilno opozivanje traži da ponovno potvrdite vlastitu lozinku i trajno briše hitni pristup kompleta.", + "Force-revoke suite": "Prisilno opozovi komplet", + "Reinstate suite": "Vrati komplet", + "Revoking this suite deleted %n emergency-access contact.": "Opozivanje ovog kompleta izbrisalo je %n kontakt hitnog pristupa.", + "Revoking this suite deleted %n emergency-access contacts.": "Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tretiraj tajne kompleta kao kompromitirane (označi za rotaciju i obavijesti vlasnike)" }, "plurals": null } diff --git a/l10n/hu.js b/l10n/hu.js index 380fba5a3..f851f5e38 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Átvétel széfadminisztrátorként", "Select {name}": "{name} kijelölése", "Could not load the password policy.": "A jelszóházirend betöltése nem sikerült.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "{total} titok közül {ok} hozzáadva a csoportmappához", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "A Keepiq böngészőkiegészítő automatikusan kitölti a bejelentkezéseit, bejelentkezési kulcsokat biztosít és TOTP-kódokat jelenít meg — a titkai pedig soha nem hagyják el az eszközét.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Létrejön egy helykitöltő, amely üres marad, amíg a címzett ki nem tölti — soha nem kell értéket kitalálnia.", - "Could not reach the directory": "A címtár nem érhető el" + "Could not reach the directory": "A címtár nem érhető el", + "Integrations": "Integrációk", + "Connection": "Kapcsolat", + "Status message": "Állapotüzenet", + "Last checked": "Utolsó ellenőrzés", + "All connections": "Minden kapcsolat", + "Add integration": "Integráció hozzáadása", + "Open settings": "Beállítások megnyitása", + "Configured": "Konfigurálva", + "Limited": "Korlátozott", + "Simulated": "Szimulált", + "Not available": "Nem érhető el", + "Error": "Hiba", + "e.g. Offboarding, device lost, key compromised": "pl. kilépés, elveszett eszköz, kulcs kompromittálódott", + "Encryption suites": "Titkosítási csomagok", + "Failed to force-revoke suite": "A csomag kényszerített visszavonása nem sikerült", + "Failed to reinstate suite": "A csomag visszaállítása nem sikerült", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Egy felhasználó vagy alkalmazás tulajdonában lévő titkosítási csomag kényszerített visszavonása azonosító alapján, amikor a tulajdonosa nem tudja (elfelejtett mesterjelszó, visszavont hozzáférés vagy kompromittálódás), és egy visszavont visszaállítása. A kényszerített visszavonás a saját jelszavának újbóli megerősítését kéri, és véglegesen törli a csomag vészhozzáférését.", + "Force-revoke suite": "Csomag kényszerített visszavonása", + "Reinstate suite": "Csomag visszaállítása", + "Revoking this suite deleted %n emergency-access contact.": "A csomag visszavonása %n vészhozzáférési névjegyet törölt.", + "Revoking this suite deleted %n emergency-access contacts.": "A csomag visszavonása %n vészhozzáférési névjegyet törölt.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "A csomag titkainak kompromittáltként kezelése (megjelölés cserére és a tulajdonosok értesítése)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index 50a3bf6cc..f4a4aa0e4 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Átvétel széfadminisztrátorként", "Select {name}": "{name} kijelölése", "Could not load the password policy.": "A jelszóházirend betöltése nem sikerült.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "{total} titok közül {ok} hozzáadva a csoportmappához", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "A Keepiq böngészőkiegészítő automatikusan kitölti a bejelentkezéseit, bejelentkezési kulcsokat biztosít és TOTP-kódokat jelenít meg — a titkai pedig soha nem hagyják el az eszközét.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Létrejön egy helykitöltő, amely üres marad, amíg a címzett ki nem tölti — soha nem kell értéket kitalálnia.", - "Could not reach the directory": "A címtár nem érhető el" + "Could not reach the directory": "A címtár nem érhető el", + "Integrations": "Integrációk", + "Connection": "Kapcsolat", + "Status message": "Állapotüzenet", + "Last checked": "Utolsó ellenőrzés", + "All connections": "Minden kapcsolat", + "Add integration": "Integráció hozzáadása", + "Open settings": "Beállítások megnyitása", + "Configured": "Konfigurálva", + "Limited": "Korlátozott", + "Simulated": "Szimulált", + "Not available": "Nem érhető el", + "Error": "Hiba", + "e.g. Offboarding, device lost, key compromised": "pl. kilépés, elveszett eszköz, kulcs kompromittálódott", + "Encryption suites": "Titkosítási csomagok", + "Failed to force-revoke suite": "A csomag kényszerített visszavonása nem sikerült", + "Failed to reinstate suite": "A csomag visszaállítása nem sikerült", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Egy felhasználó vagy alkalmazás tulajdonában lévő titkosítási csomag kényszerített visszavonása azonosító alapján, amikor a tulajdonosa nem tudja (elfelejtett mesterjelszó, visszavont hozzáférés vagy kompromittálódás), és egy visszavont visszaállítása. A kényszerített visszavonás a saját jelszavának újbóli megerősítését kéri, és véglegesen törli a csomag vészhozzáférését.", + "Force-revoke suite": "Csomag kényszerített visszavonása", + "Reinstate suite": "Csomag visszaállítása", + "Revoking this suite deleted %n emergency-access contact.": "A csomag visszavonása %n vészhozzáférési névjegyet törölt.", + "Revoking this suite deleted %n emergency-access contacts.": "A csomag visszavonása %n vészhozzáférési névjegyet törölt.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "A csomag titkainak kompromittáltként kezelése (megjelölés cserére és a tulajdonosok értesítése)" }, "plurals": null } diff --git a/l10n/is.js b/l10n/is.js index c0a8ccf07..ddda52ba0 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Taka yfir sem hirslustjórnandi", "Select {name}": "Velja {name}", "Could not load the password policy.": "Ekki tókst að hlaða lykilorðastefnuna.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Bætt {ok} af {total} leyndarmálum í hópmöppuna", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq-vafraviðbótin fyllir sjálfkrafa út innskráningar þínar, veitir aðgangslykla og birtir TOTP-kóða — án þess að leyndarmálin þín fari nokkurn tímann úr tækinu þínu.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Búinn er til frátekinn staður sem er tómur þar til viðtakandinn fyllir hann út — þú þarft aldrei að finna upp gildi.", - "Could not reach the directory": "Ekki tókst að ná sambandi við nafnaskrána" + "Could not reach the directory": "Ekki tókst að ná sambandi við nafnaskrána", + "Integrations": "Samþættingar", + "Connection": "Tenging", + "Status message": "Stöðuskilaboð", + "Last checked": "Síðast athugað", + "All connections": "Allar tengingar", + "Add integration": "Bæta við samþættingu", + "Open settings": "Opna stillingar", + "Configured": "Stillt", + "Limited": "Takmarkað", + "Simulated": "Hermt", + "Not available": "Ekki tiltækt", + "Error": "Villa", + "e.g. Offboarding, device lost, key compromised": "t.d. starfslok, tapað tæki, lykill í hættu", + "Encryption suites": "Dulkóðunarsett", + "Failed to force-revoke suite": "Þvinguð afturköllun setts mistókst", + "Failed to reinstate suite": "Endurvirkjun setts mistókst", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Þvinga afturköllun á dulkóðunarsetti í eigu notanda eða forrits eftir auðkenni þegar eigandi þess getur það ekki (gleymt aðallykilorð, afturkallaður aðgangur eða öryggisbrestur), og endurvirkja afturkallað. Þvinguð afturköllun biður þig um að staðfesta þitt eigið lykilorð aftur og eyðir neyðaraðgangi settsins varanlega.", + "Force-revoke suite": "Þvinga afturköllun setts", + "Reinstate suite": "Endurvirkja sett", + "Revoking this suite deleted %n emergency-access contact.": "Afturköllun þessa setts eyddi %n neyðaraðgangstengilið.", + "Revoking this suite deleted %n emergency-access contacts.": "Afturköllun þessa setts eyddi %n neyðaraðgangstengiliðum.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Meðhöndla leyndarmál settsins sem í hættu (merkja fyrir endurnýjun og láta eigendur vita)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 6ffacf9ef..74cc31524 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Taka yfir sem hirslustjórnandi", "Select {name}": "Velja {name}", "Could not load the password policy.": "Ekki tókst að hlaða lykilorðastefnuna.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Bætt {ok} af {total} leyndarmálum í hópmöppuna", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq-vafraviðbótin fyllir sjálfkrafa út innskráningar þínar, veitir aðgangslykla og birtir TOTP-kóða — án þess að leyndarmálin þín fari nokkurn tímann úr tækinu þínu.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Búinn er til frátekinn staður sem er tómur þar til viðtakandinn fyllir hann út — þú þarft aldrei að finna upp gildi.", - "Could not reach the directory": "Ekki tókst að ná sambandi við nafnaskrána" + "Could not reach the directory": "Ekki tókst að ná sambandi við nafnaskrána", + "Integrations": "Samþættingar", + "Connection": "Tenging", + "Status message": "Stöðuskilaboð", + "Last checked": "Síðast athugað", + "All connections": "Allar tengingar", + "Add integration": "Bæta við samþættingu", + "Open settings": "Opna stillingar", + "Configured": "Stillt", + "Limited": "Takmarkað", + "Simulated": "Hermt", + "Not available": "Ekki tiltækt", + "Error": "Villa", + "e.g. Offboarding, device lost, key compromised": "t.d. starfslok, tapað tæki, lykill í hættu", + "Encryption suites": "Dulkóðunarsett", + "Failed to force-revoke suite": "Þvinguð afturköllun setts mistókst", + "Failed to reinstate suite": "Endurvirkjun setts mistókst", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Þvinga afturköllun á dulkóðunarsetti í eigu notanda eða forrits eftir auðkenni þegar eigandi þess getur það ekki (gleymt aðallykilorð, afturkallaður aðgangur eða öryggisbrestur), og endurvirkja afturkallað. Þvinguð afturköllun biður þig um að staðfesta þitt eigið lykilorð aftur og eyðir neyðaraðgangi settsins varanlega.", + "Force-revoke suite": "Þvinga afturköllun setts", + "Reinstate suite": "Endurvirkja sett", + "Revoking this suite deleted %n emergency-access contact.": "Afturköllun þessa setts eyddi %n neyðaraðgangstengilið.", + "Revoking this suite deleted %n emergency-access contacts.": "Afturköllun þessa setts eyddi %n neyðaraðgangstengiliðum.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Meðhöndla leyndarmál settsins sem í hættu (merkja fyrir endurnýjun og láta eigendur vita)" }, "plurals": null } diff --git a/l10n/it.js b/l10n/it.js index fa7d43784..689e77127 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Prendi il controllo come amministratore della cassaforte", "Select {name}": "Seleziona {name}", "Could not load the password policy.": "Impossibile caricare il criterio delle password.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Aggiunti {ok} di {total} segreti alla cartella del team", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "L'estensione per browser di Keepiq compila automaticamente i tuoi accessi, fornisce le passkey e mostra i codici TOTP, senza che i tuoi segreti lascino mai il tuo dispositivo.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Viene creato un segnaposto che resta vuoto finché il destinatario non lo compila: non devi mai inventare un valore.", - "Could not reach the directory": "Impossibile raggiungere la rubrica" + "Could not reach the directory": "Impossibile raggiungere la rubrica", + "Integrations": "Integrazioni", + "Connection": "Connessione", + "Status message": "Messaggio di stato", + "Last checked": "Ultimo controllo", + "All connections": "Tutte le connessioni", + "Add integration": "Aggiungi integrazione", + "Open settings": "Apri impostazioni", + "Configured": "Configurato", + "Limited": "Limitato", + "Simulated": "Simulato", + "Not available": "Non disponibile", + "Error": "Errore", + "e.g. Offboarding, device lost, key compromised": "es. offboarding, dispositivo smarrito, chiave compromessa", + "Encryption suites": "Suite di cifratura", + "Failed to force-revoke suite": "Revoca forzata della suite non riuscita", + "Failed to reinstate suite": "Ripristino della suite non riuscito", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revoca forzatamente una suite di cifratura di proprietà di un utente o di un'applicazione tramite id quando il proprietario non può (una password principale dimenticata, un accesso revocato o una compromissione), e ripristina una suite revocata. La revoca forzata richiede di riconfermare la propria password ed elimina definitivamente l'accesso di emergenza della suite.", + "Force-revoke suite": "Revoca forzata della suite", + "Reinstate suite": "Ripristina la suite", + "Revoking this suite deleted %n emergency-access contact.": "La revoca di questa suite ha eliminato %n contatto di accesso di emergenza.", + "Revoking this suite deleted %n emergency-access contacts.": "La revoca di questa suite ha eliminato %n contatti di accesso di emergenza.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tratta i segreti della suite come compromessi (contrassegna per la rotazione e avvisa i proprietari)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index dfec435f3..fb2bb6ed5 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Prendi il controllo come amministratore della cassaforte", "Select {name}": "Seleziona {name}", "Could not load the password policy.": "Impossibile caricare il criterio delle password.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Aggiunti {ok} di {total} segreti alla cartella del team", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "L'estensione per browser di Keepiq compila automaticamente i tuoi accessi, fornisce le passkey e mostra i codici TOTP, senza che i tuoi segreti lascino mai il tuo dispositivo.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Viene creato un segnaposto che resta vuoto finché il destinatario non lo compila: non devi mai inventare un valore.", - "Could not reach the directory": "Impossibile raggiungere la rubrica" + "Could not reach the directory": "Impossibile raggiungere la rubrica", + "Integrations": "Integrazioni", + "Connection": "Connessione", + "Status message": "Messaggio di stato", + "Last checked": "Ultimo controllo", + "All connections": "Tutte le connessioni", + "Add integration": "Aggiungi integrazione", + "Open settings": "Apri impostazioni", + "Configured": "Configurato", + "Limited": "Limitato", + "Simulated": "Simulato", + "Not available": "Non disponibile", + "Error": "Errore", + "e.g. Offboarding, device lost, key compromised": "es. offboarding, dispositivo smarrito, chiave compromessa", + "Encryption suites": "Suite di cifratura", + "Failed to force-revoke suite": "Revoca forzata della suite non riuscita", + "Failed to reinstate suite": "Ripristino della suite non riuscito", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revoca forzatamente una suite di cifratura di proprietà di un utente o di un'applicazione tramite id quando il proprietario non può (una password principale dimenticata, un accesso revocato o una compromissione), e ripristina una suite revocata. La revoca forzata richiede di riconfermare la propria password ed elimina definitivamente l'accesso di emergenza della suite.", + "Force-revoke suite": "Revoca forzata della suite", + "Reinstate suite": "Ripristina la suite", + "Revoking this suite deleted %n emergency-access contact.": "La revoca di questa suite ha eliminato %n contatto di accesso di emergenza.", + "Revoking this suite deleted %n emergency-access contacts.": "La revoca di questa suite ha eliminato %n contatti di accesso di emergenza.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tratta i segreti della suite come compromessi (contrassegna per la rotazione e avvisa i proprietari)" }, "plurals": null } diff --git a/l10n/lb.js b/l10n/lb.js index 4b41f88fa..c5b4deaee 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Als Tresoradministrateur iwwerhuelen", "Select {name}": "{name} auswielen", "Could not load the password policy.": "D'Passwuertrichtlinn konnt net gelueden ginn.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "{ok} vun {total} Geheimnisser an den Equipendossier gesat", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "D'Keepiq-Browser-Erweiderung fëllt Är Logins automatesch aus, bitt Passkeys a weist TOTP-Coden — ouni datt Är Geheimnisser Ären Apparat jeemools verloossen.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Et gëtt e Plazhalter erstellt, deen eidel bleift bis den Empfänger en ausfëllt — Dir musst ni e Wäert erfannen.", - "Could not reach the directory": "De Verzeechnes konnt net erreecht ginn" + "Could not reach the directory": "De Verzeechnes konnt net erreecht ginn", + "Integrations": "Integratiounen", + "Connection": "Verbindung", + "Status message": "Statusmeldung", + "Last checked": "Lescht iwwerpréift", + "All connections": "All Verbindungen", + "Add integration": "Integratioun derbäisetzen", + "Open settings": "Astellungen opmaachen", + "Configured": "Konfiguréiert", + "Limited": "Limitéiert", + "Simulated": "Simuléiert", + "Not available": "Net disponibel", + "Error": "Feeler", + "e.g. Offboarding, device lost, key compromised": "e.g. Offboarding, device lost, key compromised", + "Encryption suites": "Encryption suites", + "Failed to force-revoke suite": "Failed to force-revoke suite", + "Failed to reinstate suite": "Failed to reinstate suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.", + "Force-revoke suite": "Force-revoke suite", + "Reinstate suite": "Reinstate suite", + "Revoking this suite deleted %n emergency-access contact.": "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.": "Revoking this suite deleted %n emergency-access contacts.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Treat the suite's secrets as compromised (flag for rotation and notify owners)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 6e2fb704b..dfdeb8903 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Als Tresoradministrateur iwwerhuelen", "Select {name}": "{name} auswielen", "Could not load the password policy.": "D'Passwuertrichtlinn konnt net gelueden ginn.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "{ok} vun {total} Geheimnisser an den Equipendossier gesat", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "D'Keepiq-Browser-Erweiderung fëllt Är Logins automatesch aus, bitt Passkeys a weist TOTP-Coden — ouni datt Är Geheimnisser Ären Apparat jeemools verloossen.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Et gëtt e Plazhalter erstellt, deen eidel bleift bis den Empfänger en ausfëllt — Dir musst ni e Wäert erfannen.", - "Could not reach the directory": "De Verzeechnes konnt net erreecht ginn" + "Could not reach the directory": "De Verzeechnes konnt net erreecht ginn", + "Integrations": "Integratiounen", + "Connection": "Verbindung", + "Status message": "Statusmeldung", + "Last checked": "Lescht iwwerpréift", + "All connections": "All Verbindungen", + "Add integration": "Integratioun derbäisetzen", + "Open settings": "Astellungen opmaachen", + "Configured": "Konfiguréiert", + "Limited": "Limitéiert", + "Simulated": "Simuléiert", + "Not available": "Net disponibel", + "Error": "Feeler", + "e.g. Offboarding, device lost, key compromised": "e.g. Offboarding, device lost, key compromised", + "Encryption suites": "Encryption suites", + "Failed to force-revoke suite": "Failed to force-revoke suite", + "Failed to reinstate suite": "Failed to reinstate suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.", + "Force-revoke suite": "Force-revoke suite", + "Reinstate suite": "Reinstate suite", + "Revoking this suite deleted %n emergency-access contact.": "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.": "Revoking this suite deleted %n emergency-access contacts.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Treat the suite's secrets as compromised (flag for rotation and notify owners)" }, "plurals": null } diff --git a/l10n/lt.js b/l10n/lt.js index 68335e513..cd6a5de24 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Perimti kaip saugyklos administratoriui", "Select {name}": "Pasirinkti {name}", "Could not load the password policy.": "Nepavyko įkelti slaptažodžių politikos.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Į komandos aplanką įtraukta {ok} iš {total} paslapčių", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq naršyklės priedas automatiškai užpildo jūsų prisijungimo duomenis, teikia prieigos raktus ir rodo TOTP kodus — o jūsų paslaptys niekada neišeina iš jūsų įrenginio.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Sukuriamas rezervuotas įrašas, kuris lieka tuščias, kol gavėjas jo neužpildys — jums niekada nereikia išsigalvoti reikšmės.", - "Could not reach the directory": "Nepavyko susisiekti su katalogu" + "Could not reach the directory": "Nepavyko susisiekti su katalogu", + "Integrations": "Integracijos", + "Connection": "Ryšys", + "Status message": "Būsenos pranešimas", + "Last checked": "Paskutinį kartą patikrinta", + "All connections": "Visi ryšiai", + "Add integration": "Pridėti integraciją", + "Open settings": "Atverti nustatymus", + "Configured": "Konfigūruota", + "Limited": "Ribota", + "Simulated": "Imituota", + "Not available": "Nepasiekiama", + "Error": "Klaida", + "e.g. Offboarding, device lost, key compromised": "pvz. atleidimas, prarastas įrenginys, pažeistas raktas", + "Encryption suites": "Šifravimo rinkiniai", + "Failed to force-revoke suite": "Nepavyko priverstinai atšaukti rinkinio", + "Failed to reinstate suite": "Nepavyko atkurti rinkinio", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Priverstinai atšaukti naudotojui ar programai priklausantį šifravimo rinkinį pagal id, kai jo savininkas negali (pamirštas pagrindinis slaptažodis, atšaukta prieiga arba pažeidimas), ir atkurti atšauktą. Priverstinis atšaukimas paprašo iš naujo patvirtinti jūsų paties slaptažodį ir visam laikui pašalina rinkinio avarinę prieigą.", + "Force-revoke suite": "Priverstinai atšaukti rinkinį", + "Reinstate suite": "Atkurti rinkinį", + "Revoking this suite deleted %n emergency-access contact.": "Šio rinkinio atšaukimas pašalino %n avarinės prieigos kontaktą.", + "Revoking this suite deleted %n emergency-access contacts.": "Šio rinkinio atšaukimas pašalino %n avarinės prieigos kontaktų.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Laikyti rinkinio paslaptis pažeistomis (pažymėti keitimui ir pranešti savininkams)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.json b/l10n/lt.json index 5ab5558f6..c4d9bfb90 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Perimti kaip saugyklos administratoriui", "Select {name}": "Pasirinkti {name}", "Could not load the password policy.": "Nepavyko įkelti slaptažodžių politikos.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Į komandos aplanką įtraukta {ok} iš {total} paslapčių", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq naršyklės priedas automatiškai užpildo jūsų prisijungimo duomenis, teikia prieigos raktus ir rodo TOTP kodus — o jūsų paslaptys niekada neišeina iš jūsų įrenginio.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Sukuriamas rezervuotas įrašas, kuris lieka tuščias, kol gavėjas jo neužpildys — jums niekada nereikia išsigalvoti reikšmės.", - "Could not reach the directory": "Nepavyko susisiekti su katalogu" + "Could not reach the directory": "Nepavyko susisiekti su katalogu", + "Integrations": "Integracijos", + "Connection": "Ryšys", + "Status message": "Būsenos pranešimas", + "Last checked": "Paskutinį kartą patikrinta", + "All connections": "Visi ryšiai", + "Add integration": "Pridėti integraciją", + "Open settings": "Atverti nustatymus", + "Configured": "Konfigūruota", + "Limited": "Ribota", + "Simulated": "Imituota", + "Not available": "Nepasiekiama", + "Error": "Klaida", + "e.g. Offboarding, device lost, key compromised": "pvz. atleidimas, prarastas įrenginys, pažeistas raktas", + "Encryption suites": "Šifravimo rinkiniai", + "Failed to force-revoke suite": "Nepavyko priverstinai atšaukti rinkinio", + "Failed to reinstate suite": "Nepavyko atkurti rinkinio", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Priverstinai atšaukti naudotojui ar programai priklausantį šifravimo rinkinį pagal id, kai jo savininkas negali (pamirštas pagrindinis slaptažodis, atšaukta prieiga arba pažeidimas), ir atkurti atšauktą. Priverstinis atšaukimas paprašo iš naujo patvirtinti jūsų paties slaptažodį ir visam laikui pašalina rinkinio avarinę prieigą.", + "Force-revoke suite": "Priverstinai atšaukti rinkinį", + "Reinstate suite": "Atkurti rinkinį", + "Revoking this suite deleted %n emergency-access contact.": "Šio rinkinio atšaukimas pašalino %n avarinės prieigos kontaktą.", + "Revoking this suite deleted %n emergency-access contacts.": "Šio rinkinio atšaukimas pašalino %n avarinės prieigos kontaktų.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Laikyti rinkinio paslaptis pažeistomis (pažymėti keitimui ir pranešti savininkams)" }, "plurals": null } diff --git a/l10n/lv.js b/l10n/lv.js index 172b79e12..a27cac1cb 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Pārņemt kā glabātavas administratoram", "Select {name}": "Atlasīt {name}", "Could not load the password policy.": "Neizdevās ielādēt paroļu politiku.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Komandas mapei pievienoti {ok} no {total} noslēpumiem", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq pārlūka paplašinājums automātiski aizpilda jūsu pieteikšanās datus, nodrošina piekļuves atslēgas un rāda TOTP kodus, un jūsu noslēpumi nekad neatstāj jūsu ierīci.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Tiek izveidots vietturis, kas paliek tukšs, līdz saņēmējs to aizpilda — jums nekad nav jāizdomā vērtība.", - "Could not reach the directory": "Neizdevās sasniegt direktoriju" + "Could not reach the directory": "Neizdevās sasniegt direktoriju", + "Integrations": "Integrācijas", + "Connection": "Savienojums", + "Status message": "Statusa ziņojums", + "Last checked": "Pēdējoreiz pārbaudīts", + "All connections": "Visi savienojumi", + "Add integration": "Pievienot integrāciju", + "Open settings": "Atvērt iestatījumus", + "Configured": "Konfigurēts", + "Limited": "Ierobežots", + "Simulated": "Simulēts", + "Not available": "Nav pieejams", + "Error": "Kļūda", + "e.g. Offboarding, device lost, key compromised": "piem. aiziešana no darba, pazaudēta ierīce, atslēga kompromitēta", + "Encryption suites": "Šifrēšanas komplekti", + "Failed to force-revoke suite": "Komplekta piespiedu atsaukšana neizdevās", + "Failed to reinstate suite": "Komplekta atjaunošana neizdevās", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Piespiedu kārtā atsaukt lietotājam vai lietotnei piederošu šifrēšanas komplektu pēc id, kad tā īpašnieks nevar (aizmirsta galvenā parole, atsaukta piekļuve vai kompromitēšana), un atjaunot atsauktu. Piespiedu atsaukšana lūdz vēlreiz apstiprināt jūsu paša paroli un neatgriezeniski dzēš komplekta ārkārtas piekļuvi.", + "Force-revoke suite": "Piespiedu kārtā atsaukt komplektu", + "Reinstate suite": "Atjaunot komplektu", + "Revoking this suite deleted %n emergency-access contact.": "Šī komplekta atsaukšana dzēsa %n ārkārtas piekļuves kontaktu.", + "Revoking this suite deleted %n emergency-access contacts.": "Šī komplekta atsaukšana dzēsa %n ārkārtas piekļuves kontaktus.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Uzskatīt komplekta noslēpumus par kompromitētiem (atzīmēt maiņai un paziņot īpašniekiem)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lv.json b/l10n/lv.json index 9081fb74e..570ad951e 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Pārņemt kā glabātavas administratoram", "Select {name}": "Atlasīt {name}", "Could not load the password policy.": "Neizdevās ielādēt paroļu politiku.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Komandas mapei pievienoti {ok} no {total} noslēpumiem", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq pārlūka paplašinājums automātiski aizpilda jūsu pieteikšanās datus, nodrošina piekļuves atslēgas un rāda TOTP kodus, un jūsu noslēpumi nekad neatstāj jūsu ierīci.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Tiek izveidots vietturis, kas paliek tukšs, līdz saņēmējs to aizpilda — jums nekad nav jāizdomā vērtība.", - "Could not reach the directory": "Neizdevās sasniegt direktoriju" + "Could not reach the directory": "Neizdevās sasniegt direktoriju", + "Integrations": "Integrācijas", + "Connection": "Savienojums", + "Status message": "Statusa ziņojums", + "Last checked": "Pēdējoreiz pārbaudīts", + "All connections": "Visi savienojumi", + "Add integration": "Pievienot integrāciju", + "Open settings": "Atvērt iestatījumus", + "Configured": "Konfigurēts", + "Limited": "Ierobežots", + "Simulated": "Simulēts", + "Not available": "Nav pieejams", + "Error": "Kļūda", + "e.g. Offboarding, device lost, key compromised": "piem. aiziešana no darba, pazaudēta ierīce, atslēga kompromitēta", + "Encryption suites": "Šifrēšanas komplekti", + "Failed to force-revoke suite": "Komplekta piespiedu atsaukšana neizdevās", + "Failed to reinstate suite": "Komplekta atjaunošana neizdevās", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Piespiedu kārtā atsaukt lietotājam vai lietotnei piederošu šifrēšanas komplektu pēc id, kad tā īpašnieks nevar (aizmirsta galvenā parole, atsaukta piekļuve vai kompromitēšana), un atjaunot atsauktu. Piespiedu atsaukšana lūdz vēlreiz apstiprināt jūsu paša paroli un neatgriezeniski dzēš komplekta ārkārtas piekļuvi.", + "Force-revoke suite": "Piespiedu kārtā atsaukt komplektu", + "Reinstate suite": "Atjaunot komplektu", + "Revoking this suite deleted %n emergency-access contact.": "Šī komplekta atsaukšana dzēsa %n ārkārtas piekļuves kontaktu.", + "Revoking this suite deleted %n emergency-access contacts.": "Šī komplekta atsaukšana dzēsa %n ārkārtas piekļuves kontaktus.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Uzskatīt komplekta noslēpumus par kompromitētiem (atzīmēt maiņai un paziņot īpašniekiem)" }, "plurals": null } diff --git a/l10n/mk.js b/l10n/mk.js index 682e227ce..c475211a6 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Преземи како администратор на трезорот", "Select {name}": "Избери {name}", "Could not load the password policy.": "Политиката за лозинки не можеше да се вчита.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Додадени {ok} од {total} тајни во тимската папка", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Проширувањето за прелистувач Keepiq автоматски ги пополнува вашите најави, обезбедува пристапни клучеви и прикажува TOTP кодови — а вашите тајни никогаш не го напуштаат вашиот уред.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Се создава резервирано место што останува празно додека примачот не го пополни — никогаш не треба да измислувате вредност.", - "Could not reach the directory": "До именикот не можеше да се дојде" + "Could not reach the directory": "До именикот не можеше да се дојде", + "Integrations": "Интеграции", + "Connection": "Врска", + "Status message": "Порака за статус", + "Last checked": "Последна проверка", + "All connections": "Сите врски", + "Add integration": "Додај интеграција", + "Open settings": "Отвори поставки", + "Configured": "Конфигурирано", + "Limited": "Ограничено", + "Simulated": "Симулирано", + "Not available": "Не е достапно", + "Error": "Грешка", + "e.g. Offboarding, device lost, key compromised": "на пр. напуштање, изгубен уред, компромитиран клуч", + "Encryption suites": "Комплети за шифрирање", + "Failed to force-revoke suite": "Присилното отповикување на комплетот не успеа", + "Failed to reinstate suite": "Враќањето на комплетот не успеа", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Присилно отповикај комплет за шифрирање во сопственост на корисник или апликација според id кога неговиот сопственик не може (заборавена главна лозинка, одземен пристап или компромитација) и врати отповикан. Присилното отповикување бара повторно да ја потврдите вашата лозинка и трајно го брише итниот пристап на комплетот.", + "Force-revoke suite": "Присилно отповикај комплет", + "Reinstate suite": "Врати комплет", + "Revoking this suite deleted %n emergency-access contact.": "Отповикувањето на овој комплет избриша %n контакт за итен пристап.", + "Revoking this suite deleted %n emergency-access contacts.": "Отповикувањето на овој комплет избриша %n контакти за итен пристап.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Третирај ги тајните на комплетот како компромитирани (означи за ротација и извести ги сопствениците)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index dda98c4fa..5434f5ec1 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Преземи како администратор на трезорот", "Select {name}": "Избери {name}", "Could not load the password policy.": "Политиката за лозинки не можеше да се вчита.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Додадени {ok} од {total} тајни во тимската папка", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Проширувањето за прелистувач Keepiq автоматски ги пополнува вашите најави, обезбедува пристапни клучеви и прикажува TOTP кодови — а вашите тајни никогаш не го напуштаат вашиот уред.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Се создава резервирано место што останува празно додека примачот не го пополни — никогаш не треба да измислувате вредност.", - "Could not reach the directory": "До именикот не можеше да се дојде" + "Could not reach the directory": "До именикот не можеше да се дојде", + "Integrations": "Интеграции", + "Connection": "Врска", + "Status message": "Порака за статус", + "Last checked": "Последна проверка", + "All connections": "Сите врски", + "Add integration": "Додај интеграција", + "Open settings": "Отвори поставки", + "Configured": "Конфигурирано", + "Limited": "Ограничено", + "Simulated": "Симулирано", + "Not available": "Не е достапно", + "Error": "Грешка", + "e.g. Offboarding, device lost, key compromised": "на пр. напуштање, изгубен уред, компромитиран клуч", + "Encryption suites": "Комплети за шифрирање", + "Failed to force-revoke suite": "Присилното отповикување на комплетот не успеа", + "Failed to reinstate suite": "Враќањето на комплетот не успеа", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Присилно отповикај комплет за шифрирање во сопственост на корисник или апликација според id кога неговиот сопственик не може (заборавена главна лозинка, одземен пристап или компромитација) и врати отповикан. Присилното отповикување бара повторно да ја потврдите вашата лозинка и трајно го брише итниот пристап на комплетот.", + "Force-revoke suite": "Присилно отповикај комплет", + "Reinstate suite": "Врати комплет", + "Revoking this suite deleted %n emergency-access contact.": "Отповикувањето на овој комплет избриша %n контакт за итен пристап.", + "Revoking this suite deleted %n emergency-access contacts.": "Отповикувањето на овој комплет избриша %n контакти за итен пристап.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Третирај ги тајните на комплетот како компромитирани (означи за ротација и извести ги сопствениците)" }, "plurals": null } diff --git a/l10n/mt.js b/l10n/mt.js index ade1d4d5c..cbc0311e8 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Ħu f'idejk bħala amministratur tal-kaxxaforti", "Select {name}": "Agħżel {name}", "Could not load the password policy.": "Ma setgħetx titgħabba l-politika tal-passwords.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Żdiedu {ok} minn {total} sigrieti mal-folder tat-tim", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "L-estensjoni tal-browser ta' Keepiq timla awtomatikament il-logins tiegħek, tipprovdi passkeys u turi kodiċi TOTP — mingħajr ma s-sigrieti tiegħek joħorġu qatt mill-apparat tiegħek.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Jinħoloq post żammiem li jibqa' vojt sakemm ir-riċevitur jimlih — qatt ma trid tivvinta valur.", - "Could not reach the directory": "Id-direttorju ma setax jintlaħaq" + "Could not reach the directory": "Id-direttorju ma setax jintlaħaq", + "Integrations": "Integrazzjonijiet", + "Connection": "Konnessjoni", + "Status message": "Messaġġ tal-istatus", + "Last checked": "L-aħħar verifika", + "All connections": "Il-konnessjonijiet kollha", + "Add integration": "Żid integrazzjoni", + "Open settings": "Iftaħ is-settings", + "Configured": "Ikkonfigurat", + "Limited": "Limitat", + "Simulated": "Simulat", + "Not available": "Mhux disponibbli", + "Error": "Żball", + "e.g. Offboarding, device lost, key compromised": "e.g. Offboarding, device lost, key compromised", + "Encryption suites": "Encryption suites", + "Failed to force-revoke suite": "Failed to force-revoke suite", + "Failed to reinstate suite": "Failed to reinstate suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.", + "Force-revoke suite": "Force-revoke suite", + "Reinstate suite": "Reinstate suite", + "Revoking this suite deleted %n emergency-access contact.": "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.": "Revoking this suite deleted %n emergency-access contacts.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Treat the suite's secrets as compromised (flag for rotation and notify owners)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 94970fc19..a991b48ee 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Ħu f'idejk bħala amministratur tal-kaxxaforti", "Select {name}": "Agħżel {name}", "Could not load the password policy.": "Ma setgħetx titgħabba l-politika tal-passwords.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Żdiedu {ok} minn {total} sigrieti mal-folder tat-tim", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "L-estensjoni tal-browser ta' Keepiq timla awtomatikament il-logins tiegħek, tipprovdi passkeys u turi kodiċi TOTP — mingħajr ma s-sigrieti tiegħek joħorġu qatt mill-apparat tiegħek.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Jinħoloq post żammiem li jibqa' vojt sakemm ir-riċevitur jimlih — qatt ma trid tivvinta valur.", - "Could not reach the directory": "Id-direttorju ma setax jintlaħaq" + "Could not reach the directory": "Id-direttorju ma setax jintlaħaq", + "Integrations": "Integrazzjonijiet", + "Connection": "Konnessjoni", + "Status message": "Messaġġ tal-istatus", + "Last checked": "L-aħħar verifika", + "All connections": "Il-konnessjonijiet kollha", + "Add integration": "Żid integrazzjoni", + "Open settings": "Iftaħ is-settings", + "Configured": "Ikkonfigurat", + "Limited": "Limitat", + "Simulated": "Simulat", + "Not available": "Mhux disponibbli", + "Error": "Żball", + "e.g. Offboarding, device lost, key compromised": "e.g. Offboarding, device lost, key compromised", + "Encryption suites": "Encryption suites", + "Failed to force-revoke suite": "Failed to force-revoke suite", + "Failed to reinstate suite": "Failed to reinstate suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.", + "Force-revoke suite": "Force-revoke suite", + "Reinstate suite": "Reinstate suite", + "Revoking this suite deleted %n emergency-access contact.": "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.": "Revoking this suite deleted %n emergency-access contacts.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Treat the suite's secrets as compromised (flag for rotation and notify owners)" }, "plurals": null } diff --git a/l10n/nb.js b/l10n/nb.js index 28f10740a..58b2544d5 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Overta som hvelvadministrator", "Select {name}": "Velg {name}", "Could not load the password policy.": "Kunne ikke laste passordpolicyen.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Lagt til {ok} av {total} hemmeligheter i teammappen", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq-nettleserutvidelsen fyller ut innloggingene dine automatisk, leverer tilgangsnøkler og viser TOTP-koder — uten at hemmelighetene dine noen gang forlater enheten din.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Det opprettes en plassholder som står tom til mottakeren fyller den ut — du skal aldri finne opp en verdi.", - "Could not reach the directory": "Kunne ikke nå katalogen" + "Could not reach the directory": "Kunne ikke nå katalogen", + "Integrations": "Integrasjoner", + "Connection": "Tilkobling", + "Status message": "Statusmelding", + "Last checked": "Sist kontrollert", + "All connections": "Alle tilkoblinger", + "Add integration": "Legg til integrasjon", + "Open settings": "Åpne innstillinger", + "Configured": "Konfigurert", + "Limited": "Begrenset", + "Simulated": "Simulert", + "Not available": "Ikke tilgjengelig", + "Error": "Feil", + "e.g. Offboarding, device lost, key compromised": "f.eks. avsluttet ansettelse, mistet enhet, nøkkel kompromittert", + "Encryption suites": "Krypteringssuiter", + "Failed to force-revoke suite": "Tvangstilbakekalling av suite mislyktes", + "Failed to reinstate suite": "Gjeninnføring av suite mislyktes", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Tvangstilbakekall en bruker- eller applikasjonseid krypteringssuite via id når eieren ikke kan (et glemt hovedpassord, en tilbakekalt tilgang eller en kompromittering), og gjeninnfør en tilbakekalt. Tvangstilbakekalling ber deg bekrefte ditt eget passord på nytt og fjerner suitens nødtilgang permanent.", + "Force-revoke suite": "Tvangstilbakekall suite", + "Reinstate suite": "Gjeninnfør suite", + "Revoking this suite deleted %n emergency-access contact.": "Tilbakekallingen av denne suiten fjernet %n nødtilgangskontakt.", + "Revoking this suite deleted %n emergency-access contacts.": "Tilbakekallingen av denne suiten fjernet %n nødtilgangskontakter.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Behandle suitens hemmeligheter som kompromittert (merk for rotasjon og varsle eiere)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index aad1ac3d7..ffba28e3a 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Overta som hvelvadministrator", "Select {name}": "Velg {name}", "Could not load the password policy.": "Kunne ikke laste passordpolicyen.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Lagt til {ok} av {total} hemmeligheter i teammappen", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq-nettleserutvidelsen fyller ut innloggingene dine automatisk, leverer tilgangsnøkler og viser TOTP-koder — uten at hemmelighetene dine noen gang forlater enheten din.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Det opprettes en plassholder som står tom til mottakeren fyller den ut — du skal aldri finne opp en verdi.", - "Could not reach the directory": "Kunne ikke nå katalogen" + "Could not reach the directory": "Kunne ikke nå katalogen", + "Integrations": "Integrasjoner", + "Connection": "Tilkobling", + "Status message": "Statusmelding", + "Last checked": "Sist kontrollert", + "All connections": "Alle tilkoblinger", + "Add integration": "Legg til integrasjon", + "Open settings": "Åpne innstillinger", + "Configured": "Konfigurert", + "Limited": "Begrenset", + "Simulated": "Simulert", + "Not available": "Ikke tilgjengelig", + "Error": "Feil", + "e.g. Offboarding, device lost, key compromised": "f.eks. avsluttet ansettelse, mistet enhet, nøkkel kompromittert", + "Encryption suites": "Krypteringssuiter", + "Failed to force-revoke suite": "Tvangstilbakekalling av suite mislyktes", + "Failed to reinstate suite": "Gjeninnføring av suite mislyktes", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Tvangstilbakekall en bruker- eller applikasjonseid krypteringssuite via id når eieren ikke kan (et glemt hovedpassord, en tilbakekalt tilgang eller en kompromittering), og gjeninnfør en tilbakekalt. Tvangstilbakekalling ber deg bekrefte ditt eget passord på nytt og fjerner suitens nødtilgang permanent.", + "Force-revoke suite": "Tvangstilbakekall suite", + "Reinstate suite": "Gjeninnfør suite", + "Revoking this suite deleted %n emergency-access contact.": "Tilbakekallingen av denne suiten fjernet %n nødtilgangskontakt.", + "Revoking this suite deleted %n emergency-access contacts.": "Tilbakekallingen av denne suiten fjernet %n nødtilgangskontakter.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Behandle suitens hemmeligheter som kompromittert (merk for rotasjon og varsle eiere)" }, "plurals": null } diff --git a/l10n/nl.js b/l10n/nl.js index 18fade367..c970f9f0e 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Getting started": "Aan de slag", "A short tour of the main screens. It takes under a minute, and you can close it at any point and pick it up again from the help menu.": "Een korte rondleiding langs de belangrijkste schermen. Het duurt minder dan een minuut en je kunt op elk moment stoppen en later verder gaan via het helpmenu.", "Open Dashboard from the menu.": "Open Dashboard via het menu.", @@ -1138,7 +1151,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "{ok} van {total} geheimen aan de teammap toegevoegd", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "De Keepiq-browserextensie vult je inloggegevens automatisch in, levert passkeys en toont TOTP-codes — zonder dat je geheimen ooit je apparaat verlaten.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Er wordt een placeholder aangemaakt die leeg blijft totdat de ontvanger hem invult — je hoeft nooit zelf een waarde te bedenken.", - "Could not reach the directory": "Kan de directory niet bereiken" + "Could not reach the directory": "Kan de directory niet bereiken", + "Integrations": "Koppelingen", + "Connection": "Verbinding", + "Status message": "Statusbericht", + "Last checked": "Laatst gecontroleerd", + "All connections": "Alle verbindingen", + "Add integration": "Integratie toevoegen", + "Open settings": "Instellingen openen", + "Configured": "Geconfigureerd", + "Limited": "Beperkt", + "Simulated": "Gesimuleerd", + "Not available": "Niet beschikbaar", + "Error": "Fout", + "e.g. Offboarding, device lost, key compromised": "bijv. Uitdiensttreding, apparaat verloren, sleutel gecompromitteerd", + "Encryption suites": "Versleutelingssuites", + "Failed to force-revoke suite": "Gedwongen intrekken van suite mislukt", + "Failed to reinstate suite": "Herstellen van suite mislukt", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Trek een suite van een gebruiker of applicatie gedwongen in op id wanneer de eigenaar dat niet kan (een vergeten hoofdwachtwoord, een ingetrokken toegang, of een compromittering), en herstel een ingetrokken suite. Gedwongen intrekken vraagt je je eigen wachtwoord opnieuw te bevestigen en verwijdert de noodtoegang van de suite permanent.", + "Force-revoke suite": "Suite gedwongen intrekken", + "Reinstate suite": "Suite herstellen", + "Revoking this suite deleted %n emergency-access contact.": "Het intrekken van deze suite verwijderde %n noodtoegangscontact.", + "Revoking this suite deleted %n emergency-access contacts.": "Het intrekken van deze suite verwijderde %n noodtoegangscontacten.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Behandel de geheimen van de suite als gecompromitteerd (markeer voor rotatie en waarschuw eigenaren)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index bfcf541d5..92b647983 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Getting started": "Aan de slag", "A short tour of the main screens. It takes under a minute, and you can close it at any point and pick it up again from the help menu.": "Een korte rondleiding langs de belangrijkste schermen. Het duurt minder dan een minuut en je kunt op elk moment stoppen en later verder gaan via het helpmenu.", "Open Dashboard from the menu.": "Open Dashboard via het menu.", @@ -773,7 +786,6 @@ "A group share affects me": "Een groepsdeling heeft gevolgen voor mij", "A secret is shared with me": "Er wordt een geheim met mij gedeeld", "A secret request is fulfilled or expires": "Een geheimverzoek wordt vervuld of verloopt", - "Access password": "Toegangswachtwoord", "Additional fields (optional JSON)": "Extra velden (optioneel, JSON)", "Application secrets": "Applicatiegeheimen", "Approve this application before writing secrets to it.": "Keur deze applicatie goed voordat je er geheimen naar schrijft.", @@ -783,7 +795,6 @@ "Cannot open application": "Kan applicatie niet openen", "Certificate active. The application decrypts secrets with its private key.": "Certificaat actief. De applicatie ontsleutelt geheimen met haar privésleutel.", "Choose which Keepiq events you receive notifications for": "Kies voor welke Keepiq-gebeurtenissen je meldingen ontvangt", - "Copy": "Kopiëren", "Degraded": "Verminderd", "Delete application": "Applicatie verwijderen", "Delete failed": "Verwijderen mislukt", @@ -801,7 +812,6 @@ "No pending applications": "Geen applicaties in afwachting", "No secrets accessed yet": "Nog geen geheimen geopend", "No secrets have been written for this application yet.": "Er zijn nog geen geheimen voor deze applicatie geschreven.", - "No share token in the URL.": "Geen deel-token in de URL.", "Notifications": "Meldingen", "Open admin settings": "Beheerinstellingen openen", "Pending external applications waiting for admin approval": "Externe applicaties die wachten op goedkeuring door een beheerder", @@ -809,20 +819,16 @@ "Recently accessed secrets": "Recent geopende geheimen", "Request secret fill-in": "Invullen van geheim aanvragen", "Requested fields": "Aangevraagde velden", - "Reveal secret": "Geheim tonen", "Secret written. The application can decrypt it with its private key.": "Geheim geschreven. De applicatie kan het ontsleutelen met haar privésleutel.", "Security event (compromise, revocation)": "Beveiligingsgebeurtenis (compromittering, intrekking)", "Share this link with the recipient": "Deel deze link met de ontvanger", "Show certificate": "Certificaat tonen", "These fields are encrypted in your browser with the application's public key. You will not be able to read the secret back.": "Deze velden worden in je browser versleuteld met de publieke sleutel van de applicatie. Je kunt het geheim daarna niet meer teruglezen.", - "This link is protected with a password. Enter the password you received to view the secret.": "Deze link is beveiligd met een wachtwoord. Voer het wachtwoord in dat u hebt ontvangen om het geheim te bekijken.", - "This share is not available. It may have expired or been used up.": "Deze gedeelde link is niet beschikbaar. Mogelijk is deze verlopen of opgebruikt.", "Unknown error": "Onbekende fout", "Vault session timeout for this account": "Sessietime-out van de kluis voor dit account", "Write failed": "Schrijven mislukt", "Write secret": "Geheim schrijven", "Write secret for {app}": "Geheim schrijven voor {app}", - "You have viewed this share. It will not be reachable again once the usage cap is reached.": "U hebt deze gedeelde link bekeken. Zodra het maximale aantal weergaven is bereikt, is deze niet meer bereikbaar.", "(undecryptable attachment)": "(niet te ontsleutelen bijlage)", "{count} selected": "{count} geselecteerd", "{days}d wait": "{days}d wachttijd", @@ -866,7 +872,6 @@ "Block values found in known breaches (requires the breach check gate)": "Waarden blokkeren die in bekende datalekken voorkomen (vereist de datalekcontrole)", "Browser extension": "Browserextensie", "By": "Door", - "Card number": "Kaartnummer", "Cardholder name": "Naam kaarthouder", "Category filter (empty = all events)": "Categoriefilter (leeg = alle gebeurtenissen)", "Certificate authority": "Certificaatautoriteit", @@ -883,7 +888,6 @@ "Confirm your master password": "Bevestig je masterwachtwoord", "Contact Nextcloud user ID": "Nextcloud-gebruikers-ID van het contact", "Content to send": "Inhoud om te versturen", - "Copy content": "Inhoud kopiëren", "Copy one-time code": "Eenmalige code kopiëren", "Copy private key": "Privésleutel kopiëren", "Copy this link now — it is shown only once. The content burns after {views} view(s).": "Kopieer deze link nu — hij wordt maar één keer getoond. De inhoud wordt vernietigd na {views} weergave(n).", @@ -936,7 +940,6 @@ "Failures": "Mislukt", "Fanning out to members — {done} / {total}": "Verspreiden naar leden — {done} / {total}", "FIDO Credential Exchange (CXF, unencrypted)": "FIDO Credential Exchange (CXF, onversleuteld)", - "First name": "Voornaam", "Flag all breached secrets for rotation": "Alle gelekte geheimen markeren voor rotatie", "Flag for rotation": "Markeren voor rotatie", "Forward whitelisted audit events to syslog or webhook sinks. Payloads carry sanitized metadata only — no secret value, name, login, or ciphertext ever leaves the server.": "Stuur toegestane auditgebeurtenissen door naar syslog- of webhook-sinks. De berichten bevatten uitsluitend opgeschoonde metadata — er verlaat nooit een geheime waarde, naam, inlognaam of versleutelde inhoud de server.", @@ -961,10 +964,8 @@ "IP / agent": "IP / agent", "Issued by the built-in certificate authority. Re-issuing keeps your existing key pair — nothing becomes unreadable.": "Uitgegeven door de ingebouwde certificaatautoriteit. Bij opnieuw uitgeven blijft je bestaande sleutelpaar behouden — niets wordt onleesbaar.", "Issuer": "Uitgever", - "It was burned, expired, or never existed.": "Deze is vernietigd, verlopen of heeft nooit bestaan.", "just computed": "zojuist berekend", "Last access": "Laatste toegang", - "Last name": "Achternaam", "Last success": "Laatste succes", "last used {when}": "laatst gebruikt {when}", "Leases are renewable": "Leases zijn verlengbaar", @@ -1055,8 +1056,6 @@ "Retrying delete": "Verwijderen opnieuw proberen", "Retrying move": "Verplaatsen opnieuw proberen", "Retrying share": "Delen opnieuw proberen", - "Reveal the message": "Bericht tonen", - "Revealing counts as a view — the message may burn afterwards.": "Tonen telt als een weergave — het bericht kan daarna vernietigd worden.", "Revoke a leaving employee's team-folder access and transfer their owned team secrets to a successor.": "Trek de teammaptoegang van een vertrekkende medewerker in en draag diens teamgeheimen over aan een opvolger.", "Revoke all team-folder access of \"{leaving}\" and transfer their owned team secrets to \"{successor}\"? This cannot be undone.": "Alle teammaptoegang van \"{leaving}\" intrekken en diens teamgeheimen overdragen aan \"{successor}\"? Dit kan niet ongedaan worden gemaakt.", "Revoke the app password in Nextcloud security settings at any time to disconnect the extension.": "Trek het app-wachtwoord op elk moment in via de beveiligingsinstellingen van Nextcloud om de extensie los te koppelen.", @@ -1066,7 +1065,6 @@ "Rotation due — possible compromise": "Rotatie nodig — mogelijk gecompromitteerd", "Rotation posture (ciphertext-age, not strength)": "Rotatiestatus (leeftijd van de versleutelde inhoud, niet de sterkte)", "Rotation requested": "Rotatie aangevraagd", - "Save this content now — it will not be retrievable once its views run out.": "Sla deze inhoud nu op — deze is niet meer op te vragen zodra het aantal weergaven op is.", "Seal and send": "Verzegelen en versturen", "Sealed transfer sent. No plaintext file was written.": "Verzegelde overdracht verstuurd. Er is geen onversleuteld bestand geschreven.", "Search applications": "Applicaties zoeken", @@ -1086,7 +1084,6 @@ "Site": "Website", "Snooze 24h": "24 uur uitstellen", "snoozed": "uitgesteld", - "Someone sent you a secure message": "Iemand heeft u een beveiligd bericht gestuurd", "Start a request; share the pairing code with the sending provider, then wait for the sealed transfer.": "Start een verzoek, deel de koppelcode met de verzendende aanbieder en wacht daarna op de verzegelde overdracht.", "Start encrypted request": "Versleuteld verzoek starten", "Stop sharing this folder": "Deze map niet meer delen", @@ -1112,9 +1109,7 @@ "This certificate was issued outside Keepiq, so it cannot be renewed here. Follow these steps:": "Dit certificaat is buiten Keepiq uitgegeven en kan hier dus niet worden verlengd. Volg deze stappen:", "This number does not pass the card checksum — double-check it (saving is not blocked).": "Dit nummer voldoet niet aan de controlesom van de kaart — controleer het nog eens (opslaan wordt niet geblokkeerd).", "This permanently deletes {count} secrets and revokes their shares. There is no trash — this cannot be undone.": "Dit verwijdert {count} geheimen definitief en trekt hun delingen in. Er is geen prullenbak — dit kan niet ongedaan worden gemaakt.", - "This send is gone": "Deze verzending bestaat niet meer", "This value appears in known breaches {count} times — choose another": "Deze waarde komt {count} keer voor in bekende datalekken — kies een andere", - "This was the last view — the message has now been destroyed. Save it before leaving this page.": "Dit was de laatste weergave — het bericht is nu vernietigd. Sla het op voordat u deze pagina verlaat.", "Timed out waiting for the sealed transfer": "Wachten op de verzegelde overdracht is verlopen", "Too many wrong passwords — the message has been destroyed.": "Te veel verkeerde wachtwoorden — het bericht is vernietigd.", "Total secrets: {n}": "Totaal aantal geheimen: {n}", @@ -1155,7 +1150,29 @@ "Added {ok} of {total} secrets to the team folder": "{ok} van {total} geheimen aan de teammap toegevoegd", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "De Keepiq-browserextensie vult je inloggegevens automatisch in, levert passkeys en toont TOTP-codes — zonder dat je geheimen ooit je apparaat verlaten.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Er wordt een placeholder aangemaakt die leeg blijft totdat de ontvanger hem invult — je hoeft nooit zelf een waarde te bedenken.", - "Could not reach the directory": "Kan de directory niet bereiken" + "Could not reach the directory": "Kan de directory niet bereiken", + "Integrations": "Koppelingen", + "Connection": "Verbinding", + "Status message": "Statusbericht", + "Last checked": "Laatst gecontroleerd", + "All connections": "Alle verbindingen", + "Add integration": "Integratie toevoegen", + "Open settings": "Instellingen openen", + "Configured": "Geconfigureerd", + "Limited": "Beperkt", + "Simulated": "Gesimuleerd", + "Not available": "Niet beschikbaar", + "Error": "Fout", + "e.g. Offboarding, device lost, key compromised": "bijv. Uitdiensttreding, apparaat verloren, sleutel gecompromitteerd", + "Encryption suites": "Versleutelingssuites", + "Failed to force-revoke suite": "Gedwongen intrekken van suite mislukt", + "Failed to reinstate suite": "Herstellen van suite mislukt", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Trek een suite van een gebruiker of applicatie gedwongen in op id wanneer de eigenaar dat niet kan (een vergeten hoofdwachtwoord, een ingetrokken toegang, of een compromittering), en herstel een ingetrokken suite. Gedwongen intrekken vraagt je je eigen wachtwoord opnieuw te bevestigen en verwijdert de noodtoegang van de suite permanent.", + "Force-revoke suite": "Suite gedwongen intrekken", + "Reinstate suite": "Suite herstellen", + "Revoking this suite deleted %n emergency-access contact.": "Het intrekken van deze suite verwijderde %n noodtoegangscontact.", + "Revoking this suite deleted %n emergency-access contacts.": "Het intrekken van deze suite verwijderde %n noodtoegangscontacten.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Behandel de geheimen van de suite als gecompromitteerd (markeer voor rotatie en waarschuw eigenaren)" }, "plurals": null, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/pl.js b/l10n/pl.js index f779ab675..9ad9ba28f 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Przejmij jako administrator sejfu", "Select {name}": "Zaznacz {name}", "Could not load the password policy.": "Nie można wczytać zasad haseł.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Dodano {ok} z {total} sekretów do folderu zespołu", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Rozszerzenie przeglądarki Keepiq automatycznie wypełnia Twoje loginy, dostarcza klucze dostępu i pokazuje kody TOTP — a Twoje sekrety nigdy nie opuszczają urządzenia.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Tworzony jest element zastępczy, który pozostaje pusty, dopóki odbiorca go nie wypełni — nigdy nie musisz wymyślać wartości.", - "Could not reach the directory": "Nie udało się połączyć z katalogiem" + "Could not reach the directory": "Nie udało się połączyć z katalogiem", + "Integrations": "Integracje", + "Connection": "Połączenie", + "Status message": "Komunikat o stanie", + "Last checked": "Ostatnio sprawdzono", + "All connections": "Wszystkie połączenia", + "Add integration": "Dodaj integrację", + "Open settings": "Otwórz ustawienia", + "Configured": "Skonfigurowano", + "Limited": "Ograniczone", + "Simulated": "Symulowane", + "Not available": "Niedostępne", + "Error": "Błąd", + "e.g. Offboarding, device lost, key compromised": "np. odejście pracownika, utrata urządzenia, klucz naruszony", + "Encryption suites": "Zestawy szyfrowania", + "Failed to force-revoke suite": "Nie udało się wymusić unieważnienia zestawu", + "Failed to reinstate suite": "Nie udało się przywrócić zestawu", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Wymuś unieważnienie zestawu szyfrowania należącego do użytkownika lub aplikacji według id, gdy jego właściciel nie może (zapomniane hasło główne, cofnięty dostęp lub naruszenie), i przywróć unieważniony. Wymuszone unieważnienie prosi o ponowne potwierdzenie własnego hasła i trwale usuwa dostęp awaryjny zestawu.", + "Force-revoke suite": "Wymuś unieważnienie zestawu", + "Reinstate suite": "Przywróć zestaw", + "Revoking this suite deleted %n emergency-access contact.": "Unieważnienie tego zestawu usunęło %n kontakt dostępu awaryjnego.", + "Revoking this suite deleted %n emergency-access contacts.": "Unieważnienie tego zestawu usunęło %n kontaktów dostępu awaryjnego.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Traktuj sekrety zestawu jako naruszone (oznacz do rotacji i powiadom właścicieli)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.json b/l10n/pl.json index 1f845d527..4acaa4d24 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Przejmij jako administrator sejfu", "Select {name}": "Zaznacz {name}", "Could not load the password policy.": "Nie można wczytać zasad haseł.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Dodano {ok} z {total} sekretów do folderu zespołu", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Rozszerzenie przeglądarki Keepiq automatycznie wypełnia Twoje loginy, dostarcza klucze dostępu i pokazuje kody TOTP — a Twoje sekrety nigdy nie opuszczają urządzenia.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Tworzony jest element zastępczy, który pozostaje pusty, dopóki odbiorca go nie wypełni — nigdy nie musisz wymyślać wartości.", - "Could not reach the directory": "Nie udało się połączyć z katalogiem" + "Could not reach the directory": "Nie udało się połączyć z katalogiem", + "Integrations": "Integracje", + "Connection": "Połączenie", + "Status message": "Komunikat o stanie", + "Last checked": "Ostatnio sprawdzono", + "All connections": "Wszystkie połączenia", + "Add integration": "Dodaj integrację", + "Open settings": "Otwórz ustawienia", + "Configured": "Skonfigurowano", + "Limited": "Ograniczone", + "Simulated": "Symulowane", + "Not available": "Niedostępne", + "Error": "Błąd", + "e.g. Offboarding, device lost, key compromised": "np. odejście pracownika, utrata urządzenia, klucz naruszony", + "Encryption suites": "Zestawy szyfrowania", + "Failed to force-revoke suite": "Nie udało się wymusić unieważnienia zestawu", + "Failed to reinstate suite": "Nie udało się przywrócić zestawu", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Wymuś unieważnienie zestawu szyfrowania należącego do użytkownika lub aplikacji według id, gdy jego właściciel nie może (zapomniane hasło główne, cofnięty dostęp lub naruszenie), i przywróć unieważniony. Wymuszone unieważnienie prosi o ponowne potwierdzenie własnego hasła i trwale usuwa dostęp awaryjny zestawu.", + "Force-revoke suite": "Wymuś unieważnienie zestawu", + "Reinstate suite": "Przywróć zestaw", + "Revoking this suite deleted %n emergency-access contact.": "Unieważnienie tego zestawu usunęło %n kontakt dostępu awaryjnego.", + "Revoking this suite deleted %n emergency-access contacts.": "Unieważnienie tego zestawu usunęło %n kontaktów dostępu awaryjnego.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Traktuj sekrety zestawu jako naruszone (oznacz do rotacji i powiadom właścicieli)" }, "plurals": null } diff --git a/l10n/pt.js b/l10n/pt.js index 7a46c6e21..7297df7a6 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Assumir como administrador do cofre", "Select {name}": "Selecionar {name}", "Could not load the password policy.": "Não foi possível carregar a política de palavras-passe.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Adicionados {ok} de {total} segredos à pasta de equipa", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "A extensão de navegador do Keepiq preenche automaticamente as suas credenciais, fornece chaves de acesso e mostra códigos TOTP, sem que os seus segredos saiam nunca do seu dispositivo.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "É criado um marcador que fica vazio até o destinatário o preencher — nunca precisa de inventar um valor.", - "Could not reach the directory": "Não foi possível contactar o diretório" + "Could not reach the directory": "Não foi possível contactar o diretório", + "Integrations": "Integrações", + "Connection": "Ligação", + "Status message": "Mensagem de estado", + "Last checked": "Última verificação", + "All connections": "Todas as ligações", + "Add integration": "Adicionar integração", + "Open settings": "Abrir configurações", + "Configured": "Configurado", + "Limited": "Limitado", + "Simulated": "Simulado", + "Not available": "Não disponível", + "Error": "Erro", + "e.g. Offboarding, device lost, key compromised": "por ex. saída de colaborador, dispositivo perdido, chave comprometida", + "Encryption suites": "Suites de cifragem", + "Failed to force-revoke suite": "Falha ao revogar a suite à força", + "Failed to reinstate suite": "Falha ao restaurar a suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revogar à força uma suite de cifragem pertencente a um utilizador ou aplicação pelo id quando o proprietário não pode (uma palavra-passe mestra esquecida, um acesso revogado ou um comprometimento), e restaurar uma revogada. A revogação forçada pede para reconfirmar a sua própria palavra-passe e elimina permanentemente o acesso de emergência da suite.", + "Force-revoke suite": "Revogar a suite à força", + "Reinstate suite": "Restaurar a suite", + "Revoking this suite deleted %n emergency-access contact.": "Revogar esta suite eliminou %n contacto de acesso de emergência.", + "Revoking this suite deleted %n emergency-access contacts.": "Revogar esta suite eliminou %n contactos de acesso de emergência.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tratar os segredos da suite como comprometidos (marcar para rotação e notificar os proprietários)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 96c733df6..75e11c99a 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Assumir como administrador do cofre", "Select {name}": "Selecionar {name}", "Could not load the password policy.": "Não foi possível carregar a política de palavras-passe.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Adicionados {ok} de {total} segredos à pasta de equipa", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "A extensão de navegador do Keepiq preenche automaticamente as suas credenciais, fornece chaves de acesso e mostra códigos TOTP, sem que os seus segredos saiam nunca do seu dispositivo.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "É criado um marcador que fica vazio até o destinatário o preencher — nunca precisa de inventar um valor.", - "Could not reach the directory": "Não foi possível contactar o diretório" + "Could not reach the directory": "Não foi possível contactar o diretório", + "Integrations": "Integrações", + "Connection": "Ligação", + "Status message": "Mensagem de estado", + "Last checked": "Última verificação", + "All connections": "Todas as ligações", + "Add integration": "Adicionar integração", + "Open settings": "Abrir configurações", + "Configured": "Configurado", + "Limited": "Limitado", + "Simulated": "Simulado", + "Not available": "Não disponível", + "Error": "Erro", + "e.g. Offboarding, device lost, key compromised": "por ex. saída de colaborador, dispositivo perdido, chave comprometida", + "Encryption suites": "Suites de cifragem", + "Failed to force-revoke suite": "Falha ao revogar a suite à força", + "Failed to reinstate suite": "Falha ao restaurar a suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revogar à força uma suite de cifragem pertencente a um utilizador ou aplicação pelo id quando o proprietário não pode (uma palavra-passe mestra esquecida, um acesso revogado ou um comprometimento), e restaurar uma revogada. A revogação forçada pede para reconfirmar a sua própria palavra-passe e elimina permanentemente o acesso de emergência da suite.", + "Force-revoke suite": "Revogar a suite à força", + "Reinstate suite": "Restaurar a suite", + "Revoking this suite deleted %n emergency-access contact.": "Revogar esta suite eliminou %n contacto de acesso de emergência.", + "Revoking this suite deleted %n emergency-access contacts.": "Revogar esta suite eliminou %n contactos de acesso de emergência.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tratar os segredos da suite como comprometidos (marcar para rotação e notificar os proprietários)" }, "plurals": null } diff --git a/l10n/rm.js b/l10n/rm.js index 10aaff5c9..f0ea9dfaa 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Surpigliar sco administratur da la cassaforte", "Select {name}": "Tscherner {name}", "Could not load the password policy.": "La directiva da pled-clav n'ha betg pudì vegnir chargiada.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "{ok} da {total} secrets agiuntads a la cartella da team", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "L'extensiun da navigatur da Keepiq emplenescha automaticamain Voss logins, porscha clavs d'access e mussa codes TOTP — senza che Voss secrets bandunan mai Voss apparat.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "In tegnaplazza vegn creà e resta vid fin che il retschavider l'emplenescha — ti na stos mai inventar in valur.", - "Could not reach the directory": "Impussibel da contactar il directori" + "Could not reach the directory": "Impussibel da contactar il directori", + "Integrations": "Integraziuns", + "Connection": "Colliaziun", + "Status message": "Messadi da status", + "Last checked": "Controllà l'ultima giada", + "All connections": "Tut las colliaziuns", + "Add integration": "Agiuntar ina integraziun", + "Open settings": "Avrir las configuraziuns", + "Configured": "Configurà", + "Limited": "Limità", + "Simulated": "Simulà", + "Not available": "Betg disponibel", + "Error": "Errur", + "e.g. Offboarding, device lost, key compromised": "e.g. Offboarding, device lost, key compromised", + "Encryption suites": "Encryption suites", + "Failed to force-revoke suite": "Failed to force-revoke suite", + "Failed to reinstate suite": "Failed to reinstate suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.", + "Force-revoke suite": "Force-revoke suite", + "Reinstate suite": "Reinstate suite", + "Revoking this suite deleted %n emergency-access contact.": "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.": "Revoking this suite deleted %n emergency-access contacts.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Treat the suite's secrets as compromised (flag for rotation and notify owners)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 2abeb7f3e..22026380c 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Surpigliar sco administratur da la cassaforte", "Select {name}": "Tscherner {name}", "Could not load the password policy.": "La directiva da pled-clav n'ha betg pudì vegnir chargiada.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "{ok} da {total} secrets agiuntads a la cartella da team", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "L'extensiun da navigatur da Keepiq emplenescha automaticamain Voss logins, porscha clavs d'access e mussa codes TOTP — senza che Voss secrets bandunan mai Voss apparat.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "In tegnaplazza vegn creà e resta vid fin che il retschavider l'emplenescha — ti na stos mai inventar in valur.", - "Could not reach the directory": "Impussibel da contactar il directori" + "Could not reach the directory": "Impussibel da contactar il directori", + "Integrations": "Integraziuns", + "Connection": "Colliaziun", + "Status message": "Messadi da status", + "Last checked": "Controllà l'ultima giada", + "All connections": "Tut las colliaziuns", + "Add integration": "Agiuntar ina integraziun", + "Open settings": "Avrir las configuraziuns", + "Configured": "Configurà", + "Limited": "Limità", + "Simulated": "Simulà", + "Not available": "Betg disponibel", + "Error": "Errur", + "e.g. Offboarding, device lost, key compromised": "e.g. Offboarding, device lost, key compromised", + "Encryption suites": "Encryption suites", + "Failed to force-revoke suite": "Failed to force-revoke suite", + "Failed to reinstate suite": "Failed to reinstate suite", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.", + "Force-revoke suite": "Force-revoke suite", + "Reinstate suite": "Reinstate suite", + "Revoking this suite deleted %n emergency-access contact.": "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.": "Revoking this suite deleted %n emergency-access contacts.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Treat the suite's secrets as compromised (flag for rotation and notify owners)" }, "plurals": null } diff --git a/l10n/ro.js b/l10n/ro.js index 6af73d1a3..331e68bfd 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Preia ca administrator al seifului", "Select {name}": "Selectează {name}", "Could not load the password policy.": "Politica de parole nu a putut fi încărcată.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Adăugate {ok} din {total} secrete în dosarul de echipă", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Extensia de navigator Keepiq completează automat datele dumneavoastră de conectare, furnizează chei de acces și afișează coduri TOTP — fără ca secretele dumneavoastră să vă părăsească vreodată dispozitivul.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Se creează un substituent care rămâne gol până când destinatarul îl completează — nu trebuie niciodată să inventați o valoare.", - "Could not reach the directory": "Directorul nu a putut fi contactat" + "Could not reach the directory": "Directorul nu a putut fi contactat", + "Integrations": "Integrări", + "Connection": "Conexiune", + "Status message": "Mesaj de stare", + "Last checked": "Ultima verificare", + "All connections": "Toate conexiunile", + "Add integration": "Adaugă integrare", + "Open settings": "Deschide setările", + "Configured": "Configurat", + "Limited": "Limitat", + "Simulated": "Simulat", + "Not available": "Indisponibil", + "Error": "Eroare", + "e.g. Offboarding, device lost, key compromised": "de ex. plecare din companie, dispozitiv pierdut, cheie compromisă", + "Encryption suites": "Suite de criptare", + "Failed to force-revoke suite": "Revocarea forțată a suitei a eșuat", + "Failed to reinstate suite": "Restabilirea suitei a eșuat", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revocă forțat o suită de criptare deținută de un utilizator sau de o aplicație după id atunci când proprietarul ei nu poate (o parolă principală uitată, un acces revocat sau o compromitere) și restabilește una revocată. Revocarea forțată vă cere să vă reconfirmați propria parolă și șterge definitiv accesul de urgență al suitei.", + "Force-revoke suite": "Revocă forțat suita", + "Reinstate suite": "Restabilește suita", + "Revoking this suite deleted %n emergency-access contact.": "Revocarea acestei suite a șters %n contact de acces de urgență.", + "Revoking this suite deleted %n emergency-access contacts.": "Revocarea acestei suite a șters %n contacte de acces de urgență.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tratează secretele suitei ca fiind compromise (marchează pentru rotire și notifică proprietarii)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.json b/l10n/ro.json index 0c8df7bfb..2e8bcc3e2 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Preia ca administrator al seifului", "Select {name}": "Selectează {name}", "Could not load the password policy.": "Politica de parole nu a putut fi încărcată.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Adăugate {ok} din {total} secrete în dosarul de echipă", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Extensia de navigator Keepiq completează automat datele dumneavoastră de conectare, furnizează chei de acces și afișează coduri TOTP — fără ca secretele dumneavoastră să vă părăsească vreodată dispozitivul.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Se creează un substituent care rămâne gol până când destinatarul îl completează — nu trebuie niciodată să inventați o valoare.", - "Could not reach the directory": "Directorul nu a putut fi contactat" + "Could not reach the directory": "Directorul nu a putut fi contactat", + "Integrations": "Integrări", + "Connection": "Conexiune", + "Status message": "Mesaj de stare", + "Last checked": "Ultima verificare", + "All connections": "Toate conexiunile", + "Add integration": "Adaugă integrare", + "Open settings": "Deschide setările", + "Configured": "Configurat", + "Limited": "Limitat", + "Simulated": "Simulat", + "Not available": "Indisponibil", + "Error": "Eroare", + "e.g. Offboarding, device lost, key compromised": "de ex. plecare din companie, dispozitiv pierdut, cheie compromisă", + "Encryption suites": "Suite de criptare", + "Failed to force-revoke suite": "Revocarea forțată a suitei a eșuat", + "Failed to reinstate suite": "Restabilirea suitei a eșuat", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revocă forțat o suită de criptare deținută de un utilizator sau de o aplicație după id atunci când proprietarul ei nu poate (o parolă principală uitată, un acces revocat sau o compromitere) și restabilește una revocată. Revocarea forțată vă cere să vă reconfirmați propria parolă și șterge definitiv accesul de urgență al suitei.", + "Force-revoke suite": "Revocă forțat suita", + "Reinstate suite": "Restabilește suita", + "Revoking this suite deleted %n emergency-access contact.": "Revocarea acestei suite a șters %n contact de acces de urgență.", + "Revoking this suite deleted %n emergency-access contacts.": "Revocarea acestei suite a șters %n contacte de acces de urgență.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Tratează secretele suitei ca fiind compromise (marchează pentru rotire și notifică proprietarii)" }, "plurals": null } diff --git a/l10n/ru.js b/l10n/ru.js index f84f753e2..fe2ba7033 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Принять управление как администратор хранилища", "Select {name}": "Выбрать {name}", "Could not load the password policy.": "Не удалось загрузить политику паролей.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Добавлено {ok} из {total} секретов в командную папку", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Расширение Keepiq для браузера автоматически заполняет ваши логины, предоставляет ключи доступа и показывает коды TOTP — при этом ваши секреты никогда не покидают ваше устройство.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Создаётся заготовка, которая остаётся пустой, пока получатель её не заполнит — вам никогда не нужно придумывать значение.", - "Could not reach the directory": "Не удалось связаться с каталогом" + "Could not reach the directory": "Не удалось связаться с каталогом", + "Integrations": "Интеграции", + "Connection": "Подключение", + "Status message": "Сообщение о состоянии", + "Last checked": "Последняя проверка", + "All connections": "Все подключения", + "Add integration": "Добавить интеграцию", + "Open settings": "Открыть настройки", + "Configured": "Настроено", + "Limited": "Ограничено", + "Simulated": "Имитация", + "Not available": "Недоступно", + "Error": "Ошибка", + "e.g. Offboarding, device lost, key compromised": "напр. увольнение, потеря устройства, компрометация ключа", + "Encryption suites": "Наборы шифрования", + "Failed to force-revoke suite": "Не удалось принудительно отозвать набор", + "Failed to reinstate suite": "Не удалось восстановить набор", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Принудительно отозвать набор шифрования, принадлежащий пользователю или приложению, по id, когда его владелец не может (забытый главный пароль, отозванный доступ или компрометация), и восстановить отозванный. Принудительный отзыв просит повторно подтвердить ваш собственный пароль и безвозвратно удаляет аварийный доступ набора.", + "Force-revoke suite": "Принудительно отозвать набор", + "Reinstate suite": "Восстановить набор", + "Revoking this suite deleted %n emergency-access contact.": "Отзыв этого набора удалил %n контакт аварийного доступа.", + "Revoking this suite deleted %n emergency-access contacts.": "Отзыв этого набора удалил %n контактов аварийного доступа.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Считать секреты набора скомпрометированными (отметить для ротации и уведомить владельцев)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ru.json b/l10n/ru.json index 9fe5f6204..4a4cd1951 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Принять управление как администратор хранилища", "Select {name}": "Выбрать {name}", "Could not load the password policy.": "Не удалось загрузить политику паролей.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Добавлено {ok} из {total} секретов в командную папку", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Расширение Keepiq для браузера автоматически заполняет ваши логины, предоставляет ключи доступа и показывает коды TOTP — при этом ваши секреты никогда не покидают ваше устройство.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Создаётся заготовка, которая остаётся пустой, пока получатель её не заполнит — вам никогда не нужно придумывать значение.", - "Could not reach the directory": "Не удалось связаться с каталогом" + "Could not reach the directory": "Не удалось связаться с каталогом", + "Integrations": "Интеграции", + "Connection": "Подключение", + "Status message": "Сообщение о состоянии", + "Last checked": "Последняя проверка", + "All connections": "Все подключения", + "Add integration": "Добавить интеграцию", + "Open settings": "Открыть настройки", + "Configured": "Настроено", + "Limited": "Ограничено", + "Simulated": "Имитация", + "Not available": "Недоступно", + "Error": "Ошибка", + "e.g. Offboarding, device lost, key compromised": "напр. увольнение, потеря устройства, компрометация ключа", + "Encryption suites": "Наборы шифрования", + "Failed to force-revoke suite": "Не удалось принудительно отозвать набор", + "Failed to reinstate suite": "Не удалось восстановить набор", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Принудительно отозвать набор шифрования, принадлежащий пользователю или приложению, по id, когда его владелец не может (забытый главный пароль, отозванный доступ или компрометация), и восстановить отозванный. Принудительный отзыв просит повторно подтвердить ваш собственный пароль и безвозвратно удаляет аварийный доступ набора.", + "Force-revoke suite": "Принудительно отозвать набор", + "Reinstate suite": "Восстановить набор", + "Revoking this suite deleted %n emergency-access contact.": "Отзыв этого набора удалил %n контакт аварийного доступа.", + "Revoking this suite deleted %n emergency-access contacts.": "Отзыв этого набора удалил %n контактов аварийного доступа.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Считать секреты набора скомпрометированными (отметить для ротации и уведомить владельцев)" }, "plurals": null } diff --git a/l10n/sk.js b/l10n/sk.js index 9fd4e54fe..e0bcd18ad 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Prevziať ako správca trezoru", "Select {name}": "Vybrať {name}", "Could not load the password policy.": "Zásady hesiel sa nepodarilo načítať.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Pridané {ok} z {total} tajomstiev do tímovej zložky", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Rozšírenie prehliadača Keepiq automaticky vypĺňa vaše prihlasovacie údaje, poskytuje prístupové kľúče a zobrazuje kódy TOTP — a vaše tajomstvá pritom nikdy neopustia vaše zariadenie.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Vytvorí sa zástupný záznam, ktorý zostane prázdny, kým ho príjemca nevyplní — nikdy si nemusíte hodnotu vymýšľať.", - "Could not reach the directory": "Adresár sa nepodarilo kontaktovať" + "Could not reach the directory": "Adresár sa nepodarilo kontaktovať", + "Integrations": "Integrácie", + "Connection": "Pripojenie", + "Status message": "Správa o stave", + "Last checked": "Naposledy skontrolované", + "All connections": "Všetky pripojenia", + "Add integration": "Pridať integráciu", + "Open settings": "Otvoriť nastavenia", + "Configured": "Nastavené", + "Limited": "Obmedzené", + "Simulated": "Simulované", + "Not available": "Nedostupné", + "Error": "Chyba", + "e.g. Offboarding, device lost, key compromised": "napr. odchod zamestnanca, stratené zariadenie, kompromitovaný kľúč", + "Encryption suites": "Šifrovacie sady", + "Failed to force-revoke suite": "Vynútené odvolanie sady zlyhalo", + "Failed to reinstate suite": "Obnovenie sady zlyhalo", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Vynútene odvolať šifrovaciu sadu vlastnenú používateľom alebo aplikáciou podľa id, keď to jej vlastník nemôže (zabudnuté hlavné heslo, odobraný prístup alebo kompromitácia), a obnoviť odvolanú. Vynútené odvolanie vás požiada o opätovné potvrdenie vlastného hesla a trvalo odstráni núdzový prístup sady.", + "Force-revoke suite": "Vynútene odvolať sadu", + "Reinstate suite": "Obnoviť sadu", + "Revoking this suite deleted %n emergency-access contact.": "Odvolanie tejto sady odstránilo %n kontakt núdzového prístupu.", + "Revoking this suite deleted %n emergency-access contacts.": "Odvolanie tejto sady odstránilo %n kontaktov núdzového prístupu.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Považovať tajomstvá sady za kompromitované (označiť na výmenu a upozorniť vlastníkov)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sk.json b/l10n/sk.json index 2da4905f9..f6fcdeba4 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Prevziať ako správca trezoru", "Select {name}": "Vybrať {name}", "Could not load the password policy.": "Zásady hesiel sa nepodarilo načítať.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Pridané {ok} z {total} tajomstiev do tímovej zložky", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Rozšírenie prehliadača Keepiq automaticky vypĺňa vaše prihlasovacie údaje, poskytuje prístupové kľúče a zobrazuje kódy TOTP — a vaše tajomstvá pritom nikdy neopustia vaše zariadenie.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Vytvorí sa zástupný záznam, ktorý zostane prázdny, kým ho príjemca nevyplní — nikdy si nemusíte hodnotu vymýšľať.", - "Could not reach the directory": "Adresár sa nepodarilo kontaktovať" + "Could not reach the directory": "Adresár sa nepodarilo kontaktovať", + "Integrations": "Integrácie", + "Connection": "Pripojenie", + "Status message": "Správa o stave", + "Last checked": "Naposledy skontrolované", + "All connections": "Všetky pripojenia", + "Add integration": "Pridať integráciu", + "Open settings": "Otvoriť nastavenia", + "Configured": "Nastavené", + "Limited": "Obmedzené", + "Simulated": "Simulované", + "Not available": "Nedostupné", + "Error": "Chyba", + "e.g. Offboarding, device lost, key compromised": "napr. odchod zamestnanca, stratené zariadenie, kompromitovaný kľúč", + "Encryption suites": "Šifrovacie sady", + "Failed to force-revoke suite": "Vynútené odvolanie sady zlyhalo", + "Failed to reinstate suite": "Obnovenie sady zlyhalo", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Vynútene odvolať šifrovaciu sadu vlastnenú používateľom alebo aplikáciou podľa id, keď to jej vlastník nemôže (zabudnuté hlavné heslo, odobraný prístup alebo kompromitácia), a obnoviť odvolanú. Vynútené odvolanie vás požiada o opätovné potvrdenie vlastného hesla a trvalo odstráni núdzový prístup sady.", + "Force-revoke suite": "Vynútene odvolať sadu", + "Reinstate suite": "Obnoviť sadu", + "Revoking this suite deleted %n emergency-access contact.": "Odvolanie tejto sady odstránilo %n kontakt núdzového prístupu.", + "Revoking this suite deleted %n emergency-access contacts.": "Odvolanie tejto sady odstránilo %n kontaktov núdzového prístupu.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Považovať tajomstvá sady za kompromitované (označiť na výmenu a upozorniť vlastníkov)" }, "plurals": null } diff --git a/l10n/sl.js b/l10n/sl.js index ba47c3d1f..589800680 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Prevzemi kot skrbnik trezorja", "Select {name}": "Izberi {name}", "Could not load the password policy.": "Pravilnika o geslih ni bilo mogoče naložiti.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "V ekipno mapo dodanih {ok} od {total} skrivnosti", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Razširitev brskalnika Keepiq samodejno izpolni vaše prijave, ponuja ključe za dostop in prikazuje kode TOTP — vaše skrivnosti pa nikoli ne zapustijo vaše naprave.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Ustvari se nadomestek, ki ostane prazen, dokler ga prejemnik ne izpolni — vrednosti si nikoli ni treba izmišljati.", - "Could not reach the directory": "Povezave z imenikom ni bilo mogoče vzpostaviti" + "Could not reach the directory": "Povezave z imenikom ni bilo mogoče vzpostaviti", + "Integrations": "Integracije", + "Connection": "Povezava", + "Status message": "Sporočilo o stanju", + "Last checked": "Nazadnje preverjeno", + "All connections": "Vse povezave", + "Add integration": "Dodaj integracijo", + "Open settings": "Odpri nastavitve", + "Configured": "Nastavljeno", + "Limited": "Omejeno", + "Simulated": "Simulirano", + "Not available": "Ni na voljo", + "Error": "Napaka", + "e.g. Offboarding, device lost, key compromised": "npr. odhod zaposlenega, izgubljena naprava, ogrožen ključ", + "Encryption suites": "Šifrirni kompleti", + "Failed to force-revoke suite": "Prisilni preklic kompleta ni uspel", + "Failed to reinstate suite": "Ponovna vzpostavitev kompleta ni uspela", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Prisilno prekliči šifrirni komplet v lasti uporabnika ali aplikacije po id-ju, kadar njegov lastnik tega ne more (pozabljeno glavno geslo, preklican dostop ali ogroženost), in ponovno vzpostavi preklicanega. Prisilni preklic zahteva ponovno potrditev vašega gesla in trajno izbriše zasilni dostop kompleta.", + "Force-revoke suite": "Prisilno prekliči komplet", + "Reinstate suite": "Ponovno vzpostavi komplet", + "Revoking this suite deleted %n emergency-access contact.": "Preklic tega kompleta je izbrisal %n stik zasilnega dostopa.", + "Revoking this suite deleted %n emergency-access contacts.": "Preklic tega kompleta je izbrisal %n stikov zasilnega dostopa.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Obravnavaj skrivnosti kompleta kot ogrožene (označi za menjavo in obvesti lastnike)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sl.json b/l10n/sl.json index 657a0ed99..6195d449e 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Prevzemi kot skrbnik trezorja", "Select {name}": "Izberi {name}", "Could not load the password policy.": "Pravilnika o geslih ni bilo mogoče naložiti.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "V ekipno mapo dodanih {ok} od {total} skrivnosti", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Razširitev brskalnika Keepiq samodejno izpolni vaše prijave, ponuja ključe za dostop in prikazuje kode TOTP — vaše skrivnosti pa nikoli ne zapustijo vaše naprave.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Ustvari se nadomestek, ki ostane prazen, dokler ga prejemnik ne izpolni — vrednosti si nikoli ni treba izmišljati.", - "Could not reach the directory": "Povezave z imenikom ni bilo mogoče vzpostaviti" + "Could not reach the directory": "Povezave z imenikom ni bilo mogoče vzpostaviti", + "Integrations": "Integracije", + "Connection": "Povezava", + "Status message": "Sporočilo o stanju", + "Last checked": "Nazadnje preverjeno", + "All connections": "Vse povezave", + "Add integration": "Dodaj integracijo", + "Open settings": "Odpri nastavitve", + "Configured": "Nastavljeno", + "Limited": "Omejeno", + "Simulated": "Simulirano", + "Not available": "Ni na voljo", + "Error": "Napaka", + "e.g. Offboarding, device lost, key compromised": "npr. odhod zaposlenega, izgubljena naprava, ogrožen ključ", + "Encryption suites": "Šifrirni kompleti", + "Failed to force-revoke suite": "Prisilni preklic kompleta ni uspel", + "Failed to reinstate suite": "Ponovna vzpostavitev kompleta ni uspela", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Prisilno prekliči šifrirni komplet v lasti uporabnika ali aplikacije po id-ju, kadar njegov lastnik tega ne more (pozabljeno glavno geslo, preklican dostop ali ogroženost), in ponovno vzpostavi preklicanega. Prisilni preklic zahteva ponovno potrditev vašega gesla in trajno izbriše zasilni dostop kompleta.", + "Force-revoke suite": "Prisilno prekliči komplet", + "Reinstate suite": "Ponovno vzpostavi komplet", + "Revoking this suite deleted %n emergency-access contact.": "Preklic tega kompleta je izbrisal %n stik zasilnega dostopa.", + "Revoking this suite deleted %n emergency-access contacts.": "Preklic tega kompleta je izbrisal %n stikov zasilnega dostopa.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Obravnavaj skrivnosti kompleta kot ogrožene (označi za menjavo in obvesti lastnike)" }, "plurals": null } diff --git a/l10n/sq.js b/l10n/sq.js index 46c90de01..dabf1e8e1 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Merre në dorëzim si administrator i kasafortës", "Select {name}": "Përzgjidh {name}", "Could not load the password policy.": "Rregullorja e fjalëkalimeve nuk u ngarkua dot.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "U shtuan {ok} nga {total} sekrete në dosjen e ekipit", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Shtojca e shfletuesit e Keepiq plotëson automatikisht kredencialet tuaja të hyrjes, ofron çelësa hyrjeje dhe shfaq kode TOTP — dhe sekretet tuaja nuk e lënë kurrë pajisjen tuaj.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Krijohet një mbajtëse vendi që qëndron bosh derisa marrësi ta plotësojë — nuk duhet kurrë të shpikni një vlerë.", - "Could not reach the directory": "Drejtoria nuk mund të arrihej" + "Could not reach the directory": "Drejtoria nuk mund të arrihej", + "Integrations": "Integrime", + "Connection": "Lidhje", + "Status message": "Mesazh gjendjeje", + "Last checked": "Kontrolluar së fundi", + "All connections": "Të gjitha lidhjet", + "Add integration": "Shto integrim", + "Open settings": "Hap cilësimet", + "Configured": "I konfiguruar", + "Limited": "I kufizuar", + "Simulated": "I simuluar", + "Not available": "Jo i disponueshëm", + "Error": "Gabim", + "e.g. Offboarding, device lost, key compromised": "p.sh. largim nga puna, pajisje e humbur, çelës i komprometuar", + "Encryption suites": "Suita enkriptimi", + "Failed to force-revoke suite": "Revokimi i detyruar i suitës dështoi", + "Failed to reinstate suite": "Rivendosja e suitës dështoi", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revoko me forcë një suitë enkriptimi në pronësi të një përdoruesi ose aplikacioni sipas id-së kur pronari i saj nuk mundet (një fjalëkalim kryesor i harruar, një qasje e revokuar ose një komprometim), dhe rivendos një të revokuar. Revokimi i detyruar kërkon të rikonfirmoni fjalëkalimin tuaj dhe fshin përgjithmonë qasjen e emergjencës të suitës.", + "Force-revoke suite": "Revoko me forcë suitën", + "Reinstate suite": "Rivendos suitën", + "Revoking this suite deleted %n emergency-access contact.": "Revokimi i kësaj suite fshiu %n kontakt të qasjes së emergjencës.", + "Revoking this suite deleted %n emergency-access contacts.": "Revokimi i kësaj suite fshiu %n kontakte të qasjes së emergjencës.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Trajto sekretet e suitës si të komprometuara (shëno për rotacion dhe njofto pronarët)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index 009cdfce9..86098d57e 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Merre në dorëzim si administrator i kasafortës", "Select {name}": "Përzgjidh {name}", "Could not load the password policy.": "Rregullorja e fjalëkalimeve nuk u ngarkua dot.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "U shtuan {ok} nga {total} sekrete në dosjen e ekipit", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Shtojca e shfletuesit e Keepiq plotëson automatikisht kredencialet tuaja të hyrjes, ofron çelësa hyrjeje dhe shfaq kode TOTP — dhe sekretet tuaja nuk e lënë kurrë pajisjen tuaj.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Krijohet një mbajtëse vendi që qëndron bosh derisa marrësi ta plotësojë — nuk duhet kurrë të shpikni një vlerë.", - "Could not reach the directory": "Drejtoria nuk mund të arrihej" + "Could not reach the directory": "Drejtoria nuk mund të arrihej", + "Integrations": "Integrime", + "Connection": "Lidhje", + "Status message": "Mesazh gjendjeje", + "Last checked": "Kontrolluar së fundi", + "All connections": "Të gjitha lidhjet", + "Add integration": "Shto integrim", + "Open settings": "Hap cilësimet", + "Configured": "I konfiguruar", + "Limited": "I kufizuar", + "Simulated": "I simuluar", + "Not available": "Jo i disponueshëm", + "Error": "Gabim", + "e.g. Offboarding, device lost, key compromised": "p.sh. largim nga puna, pajisje e humbur, çelës i komprometuar", + "Encryption suites": "Suita enkriptimi", + "Failed to force-revoke suite": "Revokimi i detyruar i suitës dështoi", + "Failed to reinstate suite": "Rivendosja e suitës dështoi", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Revoko me forcë një suitë enkriptimi në pronësi të një përdoruesi ose aplikacioni sipas id-së kur pronari i saj nuk mundet (një fjalëkalim kryesor i harruar, një qasje e revokuar ose një komprometim), dhe rivendos një të revokuar. Revokimi i detyruar kërkon të rikonfirmoni fjalëkalimin tuaj dhe fshin përgjithmonë qasjen e emergjencës të suitës.", + "Force-revoke suite": "Revoko me forcë suitën", + "Reinstate suite": "Rivendos suitën", + "Revoking this suite deleted %n emergency-access contact.": "Revokimi i kësaj suite fshiu %n kontakt të qasjes së emergjencës.", + "Revoking this suite deleted %n emergency-access contacts.": "Revokimi i kësaj suite fshiu %n kontakte të qasjes së emergjencës.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Trajto sekretet e suitës si të komprometuara (shëno për rotacion dhe njofto pronarët)" }, "plurals": null } diff --git a/l10n/sr.js b/l10n/sr.js index 67e3d75cf..d423ede13 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Преузми као администратор трезора", "Select {name}": "Изабери {name}", "Could not load the password policy.": "Смерница за лозинке није могла да се учита.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Додато {ok} од {total} тајни у тимску фасциклу", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Проширење прегледача Keepiq аутоматски испуњава ваше пријаве, пружа приступне кључеве и приказује TOTP кодове — а ваше тајне никада не излазе са вашег уређаја.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Креира се резервисано место које остаје празно док га прималац не испуни — никада не морате да измишљате вредност.", - "Could not reach the directory": "До именика није било могуће доћи" + "Could not reach the directory": "До именика није било могуће доћи", + "Integrations": "Интеграције", + "Connection": "Веза", + "Status message": "Порука о статусу", + "Last checked": "Последња провера", + "All connections": "Све везе", + "Add integration": "Додај интеграцију", + "Open settings": "Отвори подешавања", + "Configured": "Подешено", + "Limited": "Ограничено", + "Simulated": "Симулирано", + "Not available": "Није доступно", + "Error": "Грешка", + "e.g. Offboarding, device lost, key compromised": "нпр. одлазак запосленог, изгубљени уређај, компромитован кључ", + "Encryption suites": "Комплети шифровања", + "Failed to force-revoke suite": "Принудно опозивање комплета није успело", + "Failed to reinstate suite": "Враћање комплета није успело", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Принудно опозови комплет шифровања у власништву корисника или апликације према ид-у када његов власник то не може (заборављена главна лозинка, опозван приступ или компромитација) и врати опозвани. Принудно опозивање тражи да поново потврдите сопствену лозинку и трајно брише хитни приступ комплета.", + "Force-revoke suite": "Принудно опозови комплет", + "Reinstate suite": "Врати комплет", + "Revoking this suite deleted %n emergency-access contact.": "Опозивање овог комплета избрисало је %n контакт хитног приступа.", + "Revoking this suite deleted %n emergency-access contacts.": "Опозивање овог комплета избрисало је %n контаката хитног приступа.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Третирај тајне комплета као компромитоване (означи за ротацију и обавести власнике)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.json b/l10n/sr.json index b7e6cd6c0..4a2f5b12b 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Преузми као администратор трезора", "Select {name}": "Изабери {name}", "Could not load the password policy.": "Смерница за лозинке није могла да се учита.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Додато {ok} од {total} тајни у тимску фасциклу", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Проширење прегледача Keepiq аутоматски испуњава ваше пријаве, пружа приступне кључеве и приказује TOTP кодове — а ваше тајне никада не излазе са вашег уређаја.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Креира се резервисано место које остаје празно док га прималац не испуни — никада не морате да измишљате вредност.", - "Could not reach the directory": "До именика није било могуће доћи" + "Could not reach the directory": "До именика није било могуће доћи", + "Integrations": "Интеграције", + "Connection": "Веза", + "Status message": "Порука о статусу", + "Last checked": "Последња провера", + "All connections": "Све везе", + "Add integration": "Додај интеграцију", + "Open settings": "Отвори подешавања", + "Configured": "Подешено", + "Limited": "Ограничено", + "Simulated": "Симулирано", + "Not available": "Није доступно", + "Error": "Грешка", + "e.g. Offboarding, device lost, key compromised": "нпр. одлазак запосленог, изгубљени уређај, компромитован кључ", + "Encryption suites": "Комплети шифровања", + "Failed to force-revoke suite": "Принудно опозивање комплета није успело", + "Failed to reinstate suite": "Враћање комплета није успело", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Принудно опозови комплет шифровања у власништву корисника или апликације према ид-у када његов власник то не може (заборављена главна лозинка, опозван приступ или компромитација) и врати опозвани. Принудно опозивање тражи да поново потврдите сопствену лозинку и трајно брише хитни приступ комплета.", + "Force-revoke suite": "Принудно опозови комплет", + "Reinstate suite": "Врати комплет", + "Revoking this suite deleted %n emergency-access contact.": "Опозивање овог комплета избрисало је %n контакт хитног приступа.", + "Revoking this suite deleted %n emergency-access contacts.": "Опозивање овог комплета избрисало је %n контаката хитног приступа.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Третирај тајне комплета као компромитоване (означи за ротацију и обавести власнике)" }, "plurals": null } diff --git a/l10n/sv.js b/l10n/sv.js index 2493381c8..c80a40486 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Ta över som valvadministratör", "Select {name}": "Välj {name}", "Could not load the password policy.": "Lösenordspolicyn kunde inte läsas in.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "{ok} av {total} hemligheter tillagda i teammappen", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiqs webbläsartillägg fyller i dina inloggningar automatiskt, tillhandahåller åtkomstnycklar och visar TOTP-koder — utan att dina hemligheter någonsin lämnar din enhet.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "En platshållare skapas och förblir tom tills mottagaren fyller i den — du behöver aldrig hitta på ett värde.", - "Could not reach the directory": "Katalogen kunde inte nås" + "Could not reach the directory": "Katalogen kunde inte nås", + "Integrations": "Integrationer", + "Connection": "Anslutning", + "Status message": "Statusmeddelande", + "Last checked": "Senast kontrollerad", + "All connections": "Alla anslutningar", + "Add integration": "Lägg till integration", + "Open settings": "Öppna inställningar", + "Configured": "Konfigurerad", + "Limited": "Begränsad", + "Simulated": "Simulerad", + "Not available": "Inte tillgänglig", + "Error": "Fel", + "e.g. Offboarding, device lost, key compromised": "t.ex. avslut av anställning, förlorad enhet, nyckel komprometterad", + "Encryption suites": "Krypteringssviter", + "Failed to force-revoke suite": "Tvångsåterkallelse av svit misslyckades", + "Failed to reinstate suite": "Återinförande av svit misslyckades", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Tvångsåterkalla en användar- eller applikationsägd krypteringssvit via id när ägaren inte kan (ett glömt huvudlösenord, en återkallad behörighet eller en kompromettering), och återinför en återkallad. Tvångsåterkallelse ber dig bekräfta ditt eget lösenord igen och tar permanent bort svitens nödåtkomst.", + "Force-revoke suite": "Tvångsåterkalla svit", + "Reinstate suite": "Återinför svit", + "Revoking this suite deleted %n emergency-access contact.": "Återkallelsen av denna svit tog bort %n nödåtkomstkontakt.", + "Revoking this suite deleted %n emergency-access contacts.": "Återkallelsen av denna svit tog bort %n nödåtkomstkontakter.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Behandla svitens hemligheter som komprometterade (markera för rotation och meddela ägare)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index c3ed76219..ab6393300 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Ta över som valvadministratör", "Select {name}": "Välj {name}", "Could not load the password policy.": "Lösenordspolicyn kunde inte läsas in.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "{ok} av {total} hemligheter tillagda i teammappen", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiqs webbläsartillägg fyller i dina inloggningar automatiskt, tillhandahåller åtkomstnycklar och visar TOTP-koder — utan att dina hemligheter någonsin lämnar din enhet.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "En platshållare skapas och förblir tom tills mottagaren fyller i den — du behöver aldrig hitta på ett värde.", - "Could not reach the directory": "Katalogen kunde inte nås" + "Could not reach the directory": "Katalogen kunde inte nås", + "Integrations": "Integrationer", + "Connection": "Anslutning", + "Status message": "Statusmeddelande", + "Last checked": "Senast kontrollerad", + "All connections": "Alla anslutningar", + "Add integration": "Lägg till integration", + "Open settings": "Öppna inställningar", + "Configured": "Konfigurerad", + "Limited": "Begränsad", + "Simulated": "Simulerad", + "Not available": "Inte tillgänglig", + "Error": "Fel", + "e.g. Offboarding, device lost, key compromised": "t.ex. avslut av anställning, förlorad enhet, nyckel komprometterad", + "Encryption suites": "Krypteringssviter", + "Failed to force-revoke suite": "Tvångsåterkallelse av svit misslyckades", + "Failed to reinstate suite": "Återinförande av svit misslyckades", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Tvångsåterkalla en användar- eller applikationsägd krypteringssvit via id när ägaren inte kan (ett glömt huvudlösenord, en återkallad behörighet eller en kompromettering), och återinför en återkallad. Tvångsåterkallelse ber dig bekräfta ditt eget lösenord igen och tar permanent bort svitens nödåtkomst.", + "Force-revoke suite": "Tvångsåterkalla svit", + "Reinstate suite": "Återinför svit", + "Revoking this suite deleted %n emergency-access contact.": "Återkallelsen av denna svit tog bort %n nödåtkomstkontakt.", + "Revoking this suite deleted %n emergency-access contacts.": "Återkallelsen av denna svit tog bort %n nödåtkomstkontakter.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Behandla svitens hemligheter som komprometterade (markera för rotation och meddela ägare)" }, "plurals": null } diff --git a/l10n/tr.js b/l10n/tr.js index cfa9e11e3..c47edf136 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Kasa yöneticisi olarak devral", "Select {name}": "{name} öğesini seç", "Could not load the password policy.": "Parola ilkesi yüklenemedi.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "{total} gizliden {ok} tanesi takım klasörüne eklendi", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq tarayıcı eklentisi oturum bilgilerinizi kendiliğinden doldurur, geçiş anahtarları sağlar ve TOTP kodlarını gösterir — gizlileriniz aygıtınızdan asla ayrılmadan.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Bir yer tutucu oluşturulur ve alıcı doldurana kadar boş kalır — asla bir değer uydurmanız gerekmez.", - "Could not reach the directory": "Dizine ulaşılamadı" + "Could not reach the directory": "Dizine ulaşılamadı", + "Integrations": "Entegrasyonlar", + "Connection": "Bağlantı", + "Status message": "Durum iletisi", + "Last checked": "Son denetim", + "All connections": "Tüm bağlantılar", + "Add integration": "Entegrasyon ekle", + "Open settings": "Ayarları aç", + "Configured": "Yapılandırılmış", + "Limited": "Sınırlı", + "Simulated": "Benzetilmiş", + "Not available": "Kullanılamıyor", + "Error": "Hata", + "e.g. Offboarding, device lost, key compromised": "ör. işten ayrılma, cihaz kaybı, anahtar ele geçirildi", + "Encryption suites": "Şifreleme paketleri", + "Failed to force-revoke suite": "Paketin zorla iptali başarısız oldu", + "Failed to reinstate suite": "Paketin yeniden etkinleştirilmesi başarısız oldu", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Sahibi yapamadığında (unutulmuş ana parola, geri alınmış erişim veya ele geçirilme) bir kullanıcıya veya uygulamaya ait şifreleme paketini id ile zorla iptal et ve iptal edilmiş bir paketi yeniden etkinleştir. Zorla iptal, kendi parolanızı yeniden onaylamanızı ister ve paketin acil durum erişimini kalıcı olarak siler.", + "Force-revoke suite": "Paketi zorla iptal et", + "Reinstate suite": "Paketi yeniden etkinleştir", + "Revoking this suite deleted %n emergency-access contact.": "Bu paketin iptali %n acil durum erişim kişisini sildi.", + "Revoking this suite deleted %n emergency-access contacts.": "Bu paketin iptali %n acil durum erişim kişisini sildi.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Paketin sırlarını ele geçirilmiş olarak değerlendir (rotasyon için işaretle ve sahiplerini bilgilendir)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index 75f393017..b284a1949 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Kasa yöneticisi olarak devral", "Select {name}": "{name} öğesini seç", "Could not load the password policy.": "Parola ilkesi yüklenemedi.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "{total} gizliden {ok} tanesi takım klasörüne eklendi", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Keepiq tarayıcı eklentisi oturum bilgilerinizi kendiliğinden doldurur, geçiş anahtarları sağlar ve TOTP kodlarını gösterir — gizlileriniz aygıtınızdan asla ayrılmadan.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Bir yer tutucu oluşturulur ve alıcı doldurana kadar boş kalır — asla bir değer uydurmanız gerekmez.", - "Could not reach the directory": "Dizine ulaşılamadı" + "Could not reach the directory": "Dizine ulaşılamadı", + "Integrations": "Entegrasyonlar", + "Connection": "Bağlantı", + "Status message": "Durum iletisi", + "Last checked": "Son denetim", + "All connections": "Tüm bağlantılar", + "Add integration": "Entegrasyon ekle", + "Open settings": "Ayarları aç", + "Configured": "Yapılandırılmış", + "Limited": "Sınırlı", + "Simulated": "Benzetilmiş", + "Not available": "Kullanılamıyor", + "Error": "Hata", + "e.g. Offboarding, device lost, key compromised": "ör. işten ayrılma, cihaz kaybı, anahtar ele geçirildi", + "Encryption suites": "Şifreleme paketleri", + "Failed to force-revoke suite": "Paketin zorla iptali başarısız oldu", + "Failed to reinstate suite": "Paketin yeniden etkinleştirilmesi başarısız oldu", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Sahibi yapamadığında (unutulmuş ana parola, geri alınmış erişim veya ele geçirilme) bir kullanıcıya veya uygulamaya ait şifreleme paketini id ile zorla iptal et ve iptal edilmiş bir paketi yeniden etkinleştir. Zorla iptal, kendi parolanızı yeniden onaylamanızı ister ve paketin acil durum erişimini kalıcı olarak siler.", + "Force-revoke suite": "Paketi zorla iptal et", + "Reinstate suite": "Paketi yeniden etkinleştir", + "Revoking this suite deleted %n emergency-access contact.": "Bu paketin iptali %n acil durum erişim kişisini sildi.", + "Revoking this suite deleted %n emergency-access contacts.": "Bu paketin iptali %n acil durum erişim kişisini sildi.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Paketin sırlarını ele geçirilmiş olarak değerlendir (rotasyon için işaretle ve sahiplerini bilgilendir)" }, "plurals": null } diff --git a/l10n/uk.js b/l10n/uk.js index efb114362..bd93f80a7 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1,6 +1,19 @@ OC.L10N.register( "keepiq", { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Перебрати як адміністратор сховища", "Select {name}": "Вибрати {name}", "Could not load the password policy.": "Не вдалося завантажити політику паролів.", @@ -1133,7 +1146,29 @@ OC.L10N.register( "Added {ok} of {total} secrets to the team folder": "Додано {ok} з {total} секретів до командної теки", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Розширення Keepiq для браузера автоматично заповнює ваші логіни, надає ключі доступу та показує коди TOTP — і ваші секрети ніколи не покидають ваш пристрій.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Створюється заготовка, яка залишається порожньою, доки отримувач її не заповнить — вам ніколи не потрібно вигадувати значення.", - "Could not reach the directory": "Не вдалося зв'язатися з каталогом" + "Could not reach the directory": "Не вдалося зв'язатися з каталогом", + "Integrations": "Інтеграції", + "Connection": "Підключення", + "Status message": "Повідомлення про стан", + "Last checked": "Остання перевірка", + "All connections": "Усі підключення", + "Add integration": "Додати інтеграцію", + "Open settings": "Відкрити налаштування", + "Configured": "Налаштовано", + "Limited": "Обмежено", + "Simulated": "Імітовано", + "Not available": "Недоступно", + "Error": "Помилка", + "e.g. Offboarding, device lost, key compromised": "напр. звільнення, втрата пристрою, компрометація ключа", + "Encryption suites": "Набори шифрування", + "Failed to force-revoke suite": "Не вдалося примусово відкликати набір", + "Failed to reinstate suite": "Не вдалося відновити набір", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Примусово відкликати набір шифрування, що належить користувачу або застосунку, за id, коли його власник не може (забутий головний пароль, відкликаний доступ або компрометація), і відновити відкликаний. Примусове відкликання просить повторно підтвердити ваш власний пароль і назавжди видаляє аварійний доступ набору.", + "Force-revoke suite": "Примусово відкликати набір", + "Reinstate suite": "Відновити набір", + "Revoking this suite deleted %n emergency-access contact.": "Відкликання цього набору видалило %n контакт аварійного доступу.", + "Revoking this suite deleted %n emergency-access contacts.": "Відкликання цього набору видалило %n контактів аварійного доступу.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Вважати секрети набору скомпрометованими (позначити для ротації та сповістити власників)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 10042cf39..44514b20c 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1,5 +1,18 @@ { "translations": { + "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.", + "Revoke and delete emergency access": "Revoke and delete emergency access", + "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "Abort and keep my old key": "Abort and keep my old key", + "Aborting…": "Aborting…", + "Could not abort the rotation.": "Could not abort the rotation.", + "Could not revoke. Check your master password.": "Could not revoke. Check your master password.", + "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.", + "Re-enter your previous master password to finish": "Re-enter your previous master password to finish", + "Revoke emergency access": "Revoke emergency access", + "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.", "Take over as vault administrator": "Перебрати як адміністратор сховища", "Select {name}": "Вибрати {name}", "Could not load the password policy.": "Не вдалося завантажити політику паролів.", @@ -1132,7 +1145,29 @@ "Added {ok} of {total} secrets to the team folder": "Додано {ok} з {total} секретів до командної теки", "The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Розширення Keepiq для браузера автоматично заповнює ваші логіни, надає ключі доступу та показує коди TOTP — і ваші секрети ніколи не покидають ваш пристрій.", "A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Створюється заготовка, яка залишається порожньою, доки отримувач її не заповнить — вам ніколи не потрібно вигадувати значення.", - "Could not reach the directory": "Не вдалося зв'язатися з каталогом" + "Could not reach the directory": "Не вдалося зв'язатися з каталогом", + "Integrations": "Інтеграції", + "Connection": "Підключення", + "Status message": "Повідомлення про стан", + "Last checked": "Остання перевірка", + "All connections": "Усі підключення", + "Add integration": "Додати інтеграцію", + "Open settings": "Відкрити налаштування", + "Configured": "Налаштовано", + "Limited": "Обмежено", + "Simulated": "Імітовано", + "Not available": "Недоступно", + "Error": "Помилка", + "e.g. Offboarding, device lost, key compromised": "напр. звільнення, втрата пристрою, компрометація ключа", + "Encryption suites": "Набори шифрування", + "Failed to force-revoke suite": "Не вдалося примусово відкликати набір", + "Failed to reinstate suite": "Не вдалося відновити набір", + "Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Примусово відкликати набір шифрування, що належить користувачу або застосунку, за id, коли його власник не може (забутий головний пароль, відкликаний доступ або компрометація), і відновити відкликаний. Примусове відкликання просить повторно підтвердити ваш власний пароль і назавжди видаляє аварійний доступ набору.", + "Force-revoke suite": "Примусово відкликати набір", + "Reinstate suite": "Відновити набір", + "Revoking this suite deleted %n emergency-access contact.": "Відкликання цього набору видалило %n контакт аварійного доступу.", + "Revoking this suite deleted %n emergency-access contacts.": "Відкликання цього набору видалило %n контактів аварійного доступу.", + "Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Вважати секрети набору скомпрометованими (позначити для ротації та сповістити власників)" }, "plurals": null } diff --git a/lib/AppInfo/DomainOverrideRegistrar.php b/lib/AppInfo/DomainOverrideRegistrar.php index c964b38e5..e6e92ba1d 100644 --- a/lib/AppInfo/DomainOverrideRegistrar.php +++ b/lib/AppInfo/DomainOverrideRegistrar.php @@ -69,14 +69,9 @@ public function register(IRegistrationContext $context): void { eventDispatcher: $c->get(\OCP\EventDispatcher\IEventDispatcher::class), ) ); - $context->registerService( - SettingsController::class, - static fn ($c) => new SettingsController( - request: $c->get(\OCP\IRequest::class), - settingsService: $c->get(SettingsService::class), - userSession: $c->get(\OCP\IUserSession::class), - ) - ); + // SettingsControllerFactory spells out every argument, the integriq + // connection reporter included (adopt-connection-registry). + $context->registerService(SettingsController::class, new SettingsControllerFactory()); $context->registerService( InitializeSettings::class, static fn ($c) => new InitializeSettings( diff --git a/lib/AppInfo/PlatformIntegrationRegistrar.php b/lib/AppInfo/PlatformIntegrationRegistrar.php index 2c21c1faf..3337b5e86 100644 --- a/lib/AppInfo/PlatformIntegrationRegistrar.php +++ b/lib/AppInfo/PlatformIntegrationRegistrar.php @@ -23,6 +23,7 @@ namespace OCA\Keepiq\AppInfo; use OCA\Keepiq\Middleware\JwtAuthMiddleware; +use OCA\Keepiq\Middleware\VaultKeyProofMiddleware; use OCA\Keepiq\Notification\KeepiqNotifier; use OCA\Keepiq\Search\SecretSearchProvider; use OCP\AppFramework\Bootstrap\IRegistrationContext; @@ -63,5 +64,10 @@ public function register(IRegistrationContext $context): void { // controllers pass through untouched. $context->registerMiddleware(JwtAuthMiddleware::class); + // The vault-key-proof middleware. Runs for every controller but acts + // only on methods carrying #[VaultKeyProofRequired]; every other method + // passes through untouched. + $context->registerMiddleware(VaultKeyProofMiddleware::class); + }//end register() }//end class diff --git a/lib/AppInfo/SettingsControllerFactory.php b/lib/AppInfo/SettingsControllerFactory.php new file mode 100644 index 000000000..e3e0f913a --- /dev/null +++ b/lib/AppInfo/SettingsControllerFactory.php @@ -0,0 +1,65 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\AppInfo; + +use OCA\Keepiq\Controller\SettingsController; +use OCA\Keepiq\Service\Connection\ConnectionReporter; +use OCA\Keepiq\Service\SettingsService; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Container\ContainerInterface; + +/** + * Container factory for SettingsController. + * + * Every constructor argument is spelled out by name. The reporter matters most: + * the controller's default for it is null, so a factory that forgot it would + * still build, and the breach check refresh would stop without a sound. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ +final class SettingsControllerFactory { + + /** + * Build the controller from the container. + * + * @param ContainerInterface $container The app container. + * + * @return SettingsController + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ + public function __invoke(ContainerInterface $container): SettingsController { + return new SettingsController( + request: $container->get(IRequest::class), + settingsService: $container->get(SettingsService::class), + userSession: $container->get(IUserSession::class), + connectionReporter: $container->get(ConnectionReporter::class), + ); + }//end __invoke() +}//end class diff --git a/lib/AppInfo/SuiteLifecycleEventRegistrar.php b/lib/AppInfo/SuiteLifecycleEventRegistrar.php index a9063c35b..c0fa85e2b 100644 --- a/lib/AppInfo/SuiteLifecycleEventRegistrar.php +++ b/lib/AppInfo/SuiteLifecycleEventRegistrar.php @@ -24,12 +24,15 @@ namespace OCA\Keepiq\AppInfo; use OCA\Keepiq\Event\EncryptionSuiteRevokedEvent; +use OCA\Keepiq\Event\SuiteMigrationAbortedEvent; use OCA\Keepiq\Event\SuiteMigrationCompletedEvent; use OCA\Keepiq\Event\SuiteMigrationStartedEvent; use OCA\Keepiq\Listener\EmergencyAccessSuiteRevocationListener; use OCA\Keepiq\Listener\EmergencyAccessSuiteRotationListener; use OCA\Keepiq\Listener\EncryptionSuiteRevokedListener; use OCA\Keepiq\Listener\SuiteCompromiseListener; +use OCA\Keepiq\Listener\SuiteCompromiseOnRevokeListener; +use OCA\Keepiq\Listener\SuiteMigrationAbortedListener; use OCA\Keepiq\Listener\SuiteMigrationCompletedListener; use OCA\Keepiq\Listener\SuiteMigrationStartedListener; use OCP\AppFramework\Bootstrap\IRegistrationContext; @@ -42,10 +45,17 @@ * every registered listener for an event and a failure in one is contained by * that listener, not by this registration. * - * Grouped as one registrar because all six listeners share a single trigger + * Grouped as one registrar because all the listeners share a single trigger * family (a suite started migrating, finished migrating, or was revoked) and * a single invariant: no ciphertext may survive a suite it can no longer be * decrypted under. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) This registrar's sole job is + * to name the suite-lifecycle event/listener graph, so its coupling is the + * size of that graph and grows by one with each listener it wires (the + * admin-suite-revocation compromise listener is the latest). Splitting it + * would fragment one trigger family across files without reducing any real + * dependency. */ final class SuiteLifecycleEventRegistrar { /** @@ -70,6 +80,15 @@ public function register(IRegistrationContext $context): void { listener: SuiteMigrationCompletedListener::class ); + // Abort: release the SecretRequests locked at start, keeping them on the + // old suite. Deliberately bound ONLY to this listener — none of the + // terminal-cascade listeners above may react to an abort, since nothing + // migrated and the old suite stays active. + $context->registerEventListener( + event: SuiteMigrationAbortedEvent::class, + listener: SuiteMigrationAbortedListener::class + ); + // Implement-user-sharing §8 — sharing-graph reactions to suite // revocation and post-migration possibly-compromised flagging. $context->registerEventListener( @@ -81,6 +100,15 @@ public function register(IRegistrationContext $context): void { listener: SuiteCompromiseListener::class ); + // Admin force-revoke compromise cascade (admin-suite-revocation D2): + // on the SAME revoke event, but only when the administrator flagged the + // revocation as a compromise — stamp/flag/notify over the revoked + // suite's blast radius. A no-op on the owner path (flag stays false). + $context->registerEventListener( + event: EncryptionSuiteRevokedEvent::class, + listener: SuiteCompromiseOnRevokeListener::class + ); + // Emergency access — invalidate/clear recovery envelopes on a grantor's // suite rotation (compromise recovery) or revocation, and invalidate // envelopes to a grantee whose suite is revoked (add-emergency-access §3). diff --git a/lib/Attribute/VaultKeyProofRequired.php b/lib/Attribute/VaultKeyProofRequired.php new file mode 100644 index 000000000..5ab5e4672 --- /dev/null +++ b/lib/Attribute/VaultKeyProofRequired.php @@ -0,0 +1,92 @@ + + * @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\Attribute; + +use Attribute; + +/** + * Require a verified vault-key proof on the annotated controller method. + */ +#[Attribute(Attribute::TARGET_METHOD)] +class VaultKeyProofRequired { + /** + * Constructor. + * + * @param string[] $binds Request parameter names the proof commits to, in + * the order they are hashed into the signed payload. + * Empty means the proof binds to the challenge alone. + * @param string $subject Whose public key verifies the proof: + * 'active' (default) — the caller's active suite; + * 'routeParam:' — the suite named by that route + * parameter. + * @param string $purpose A stable public identifier for this operation. A + * challenge is bound to one purpose, so a proof + * obtained for one guarded operation cannot be + * presented to another. The client requests its + * challenge with the same string. + * + * @return void + */ + public function __construct( + private array $binds = [], + private string $subject = 'active', + private string $purpose = '', + ) { + }//end __construct() + + /** + * The request parameter names the proof binds to, in payload order. + * + * @return string[] + */ + public function getBinds(): array { + return $this->binds; + }//end getBinds() + + /** + * How the subject suite is resolved. + * + * @return string + */ + public function getSubject(): string { + return $this->subject; + }//end getSubject() + + /** + * The stable purpose identifier this operation's challenge is bound to. + * + * @return string + */ + public function getPurpose(): string { + return $this->purpose; + }//end getPurpose() +}//end class diff --git a/lib/Controller/BreachProxyController.php b/lib/Controller/BreachProxyController.php index fac10f103..25a4f682c 100644 --- a/lib/Controller/BreachProxyController.php +++ b/lib/Controller/BreachProxyController.php @@ -31,6 +31,7 @@ namespace OCA\Keepiq\Controller; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\Connection\ConnectionReporter; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -91,8 +92,11 @@ class BreachProxyController extends Controller { * @param ICacheFactory $cacheFactory The cache factory * @param IUserSession $userSession The user session (auth posture) * @param LoggerInterface $logger The logger + * @param ConnectionReporter|null $connectionReporter Tells integriq what an upstream lookup met, or nothing when absent. * * @return void + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-003-a-report-names-a-status-code-or-a-host-and-nothing-a-user-typed */ public function __construct( IRequest $request, @@ -101,6 +105,7 @@ public function __construct( ICacheFactory $cacheFactory, private IUserSession $userSession, private LoggerInterface $logger, + private ?ConnectionReporter $connectionReporter = null, ) { parent::__construct(appName: Application::APP_ID, request: $request); // Namespace deliberately still `keepiq_` after the doriath -> keepiq @@ -133,11 +138,18 @@ public function __construct( * gate-7 correctly stops treating that 403 as a guard once it requires a * 403 to have consulted the caller. * + * A call that reaches the upstream reports its HTTP status to integriq's + * connection registry, at most once an hour while it stays the same + * (adopt-connection-registry). Only the status travels: never the prefix, + * the suffix list or the exception, whose message names the full URL. A + * cache hit and every refusal before the call report nothing. + * * @NoAdminRequired * * @return DataResponse * * @spec openspec/changes/password-health/specs/password-health/spec.md#requirement-opt-in-breach-checking-via-k-anonymity + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-003-a-report-names-a-status-code-or-a-host-and-nothing-a-user-typed */ #[NoAdminRequired] public function range(string $prefix): DataResponse { @@ -179,19 +191,59 @@ public function range(string $prefix): DataResponse { ); $body = (string)$response->getBody(); } catch (Throwable $e) { - // Soft-degrade: never log the prefix together with a user id (privacy). + // Soft-degrade. Never log the prefix together with a user id + // (privacy), and the exception is exactly that pairing: the client's + // message names the request URL, which ends in the prefix, and + // Nextcloud stamps every line with the user who typed the password. + // So the class and the HTTP status go in the line and the message + // goes nowhere, not even as an `exception` context key, which the + // log writer would render in full. + $httpStatus = $this->connectionReporter?->httpStatusOf(exception: $e); $this->logger->warning( - 'Keepiq: HIBP range lookup failed: ' . $e->getMessage(), + 'Keepiq: HIBP range lookup failed: ' . $e::class . ' ' . $this->outcomeOf(httpStatus: $httpStatus), ['app' => Application::APP_ID] ); + $this->reportLookup(httpStatus: $httpStatus); return new DataResponse( data: ['message' => 'Breach service unavailable'], statusCode: Http::STATUS_SERVICE_UNAVAILABLE ); }//end try + $this->reportLookup(httpStatus: $response->getStatusCode()); $this->cache->set($prefix, $body, self::CACHE_TTL); return new DataResponse(data: ['suffixes' => $body]); }//end range() + + /** + * Hand the upstream's HTTP status, and nothing else, to the connection reporter. + * + * @param int|null $httpStatus The upstream's HTTP status, or null when nothing answered. + * + * @return void + */ + private function reportLookup(?int $httpStatus): void { + $this->connectionReporter?->reportBreachLookup(httpStatus: $httpStatus); + }//end reportLookup() + + /** + * What the upstream did, for the log, as a status or as silence. + * + * This is the half of the log line an admin reads to tell "Have I Been + * Pwned is down" (no answer) from "it refused us" (HTTP 429, HTTP 403). + * It is derived from the answer the exception carries, never from its + * message, so it can hold only a number. + * + * @param int|null $httpStatus The upstream's HTTP status, or null when nothing answered. + * + * @return string + */ + private function outcomeOf(?int $httpStatus): string { + if ($httpStatus === null) { + return '(no answer)'; + } + + return '(HTTP ' . $httpStatus . ')'; + }//end outcomeOf() }//end class diff --git a/lib/Controller/EmergencyAccessController.php b/lib/Controller/EmergencyAccessController.php index 502a77b0e..50b1bcbde 100644 --- a/lib/Controller/EmergencyAccessController.php +++ b/lib/Controller/EmergencyAccessController.php @@ -30,9 +30,11 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Attribute\VaultKeyProofRequired; use OCA\Keepiq\Exception\ForbiddenException; use OCA\Keepiq\Exception\NotFoundException; use OCA\Keepiq\Service\EmergencyAccessService; +use OCA\Keepiq\Service\VaultKeyProofService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; @@ -192,6 +194,11 @@ public function create( * @spec openspec/changes/add-emergency-access/specs/emergency-access/spec.md#requirement-revoke-emergency-contact */ #[NoAdminRequired] + #[VaultKeyProofRequired( + binds: ['id'], + subject: 'active', + purpose: VaultKeyProofService::PURPOSE_EMERGENCY_DESTROY + )] public function destroy(string $id): JSONResponse { $userId = $this->requireUserId(); if ($userId === null) { diff --git a/lib/Controller/EncryptionSuiteController.php b/lib/Controller/EncryptionSuiteController.php index 56331906d..d0c430581 100644 --- a/lib/Controller/EncryptionSuiteController.php +++ b/lib/Controller/EncryptionSuiteController.php @@ -25,12 +25,16 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Exception\ConflictException; +use OCA\Keepiq\Attribute\VaultKeyProofRequired; +use OCA\Keepiq\Service\EmergencyEnvelopeInvalidationService; use OCA\Keepiq\Service\EncryptionSuiteService; use OCA\Keepiq\Service\MigrationService; +use OCA\Keepiq\Service\VaultKeyProofService; use OCA\Keepiq\Settings\AdminSettings; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; @@ -39,6 +43,14 @@ /** * API controller for EncryptionSuite CRUD operations. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The suite lifecycle this + * controller owns — create, show, revoke, reinstate, routine re-key, + * compromise recovery and now vault-key-proof challenge issuance — legitimately + * coordinates several services and the guard attribute. Adding + * VaultKeyProofService for the challenge endpoint pushed it to 13; splitting + * the challenge onto its own controller would add a route surface for one + * trivial method without reducing the domain coupling that the rest carries. */ class EncryptionSuiteController extends OCSController { /** @@ -48,6 +60,8 @@ class EncryptionSuiteController extends OCSController { * @param EncryptionSuiteService $suiteService The suite service * @param MigrationService $migrationService The migration service * @param IUserSession $userSession The user session + * @param VaultKeyProofService $proofService The vault-key-proof service (issues challenges) + * @param EmergencyEnvelopeInvalidationService $emergencyService The emergency-envelope service (revoke safeguard) * @param \OCA\Keepiq\Service\PasskeyService|null $passkeyService The passkey service (passkey vault login; null when unwired) * * @return void @@ -57,6 +71,8 @@ public function __construct( private EncryptionSuiteService $suiteService, private MigrationService $migrationService, private IUserSession $userSession, + private VaultKeyProofService $proofService, + private EmergencyEnvelopeInvalidationService $emergencyService, private ?\OCA\Keepiq\Service\PasskeyService $passkeyService = null, ) { parent::__construct(appName: Application::APP_ID, request: $request); @@ -230,6 +246,11 @@ public function create( * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-2 */ #[NoAdminRequired] + #[VaultKeyProofRequired( + binds: ['encryptedPrivateKey'], + subject: 'routeParam:id', + purpose: VaultKeyProofService::PURPOSE_UPDATE_PRIVATE_KEY + )] public function updatePrivateKey(string $id, string $encryptedPrivateKey): JSONResponse { try { $suite = $this->suiteService->getSuite($id); @@ -256,17 +277,43 @@ public function updatePrivateKey(string $id, string $encryptedPrivateKey): JSONR /** * Revoke an EncryptionSuite. * + * Guarded by a vault-key proof: revocation is irreversible for the owner + * (reinstate is admin-only), hard-deletes ShareTargets, promotes delegations + * and blocks every secret read — the #395 session-only lockout shape. Requiring + * a proof signed with the suite's own private key means a stolen session, leaked + * app password or XSS in an unlocked tab cannot revoke the vault; only the owner, + * with their master password, can. An owner who has LOST that password revokes + * via the (separate, admin-only) recovery path, never this one. + * + * Revocation also deletes the owner's emergency-access recovery envelopes + * outright (the revocation listener runs clearForGrantorRevocation), so while a + * usable (non-invalidated) emergency contact exists it is refused unless the + * caller passes $acceptEmergencyLoss; the refusal surfaces the COUNT of usable + * contacts (never their identities) so the choice is made knowingly. An + * emergency accessor must retrieve the secrets first, while the suite is still + * active. + * * @param string $id The suite ID * @param string $reason The revocation reason + * @param bool $acceptEmergencyLoss Proceed even though emergency access will be deleted * * @NoAdminRequired * * @return JSONResponse * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $acceptEmergencyLoss is a + * knowing-consent flag carried in the POST body and bound by name by the + * Nextcloud router, not a mode switch the caller toggles between two + * behaviours: it only lifts the safeguard refusal. Splitting the method + * would split the route and change the HTTP contract. + * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-2 + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-irreversible-operations-require-a-verified-key-proof + * @spec openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md#requirement-envelope-invalidation-on-key-change */ #[NoAdminRequired] - public function revoke(string $id, string $reason): JSONResponse { + #[VaultKeyProofRequired(binds: ['reason', 'acceptEmergencyLoss'], subject: 'routeParam:id', purpose: VaultKeyProofService::PURPOSE_REVOKE_SUITE)] + public function revoke(string $id, string $reason, bool $acceptEmergencyLoss = false): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); @@ -287,6 +334,27 @@ public function revoke(string $id, string $reason): JSONResponse { // already call this same helper; revoke() did not. $this->validateOwnership(suite: $this->suiteService->getSuite($id)); + // Refuse to silently destroy a still-usable break-glass path. The + // envelope clear runs asynchronously in EmergencyAccessSuiteRevocation- + // Listener, downstream of the event revokeSuite dispatches, so the + // safeguard must gate HERE, before that call. Only the count crosses + // the wire — the contacts' identities stay grantor-private. + if ($acceptEmergencyLoss === false) { + $usableContacts = $this->emergencyService->countUsableForGrantorSuite($id); + if ($usableContacts > 0) { + return new JSONResponse( + data: [ + 'error' => 'emergency_access_present', + 'usableEmergencyContacts' => $usableContacts, + 'message' => 'Revoking this suite permanently deletes its emergency access. ' + . 'Any emergency accessor must retrieve the secrets first, while the suite is still active. ' + . 'Confirm to proceed.', + ], + statusCode: Http::STATUS_CONFLICT + ); + } + } + $suite = $this->suiteService->revokeSuite(id: $id, reason: $reason, revokedBy: $userId); return new JSONResponse(data: $suite->jsonSerialize()); } catch (RuntimeException $e) { @@ -328,6 +396,92 @@ public function reinstate(string $id): JSONResponse { } }//end reinstate() + /** + * Force-revoke any EncryptionSuite by id (administrator only). + * + * The administrator counterpart to the owner's proof-gated revoke(): the + * vault is zero-knowledge, so an administrator holds no vault key to sign the + * revoke challenge (ADR-003/ADR-005). Authorisation is the admin guard plus + * Nextcloud sudo (re-confirm the administrator's OWN password), NOT a + * vault-key proof; this is the only revocation path for a locked-out owner, a + * de-authorised departure, a compromise, or an application-owned suite with no + * human owner. It deliberately does NOT call validateOwnership() — cross-owner + * revocation is the whole point, and the AuthorizedAdminSetting guard (which + * reinstate() also relies on) is the authorization, so no-admin-idor must read + * this as an admin-guarded method, not an unguarded NoAdminRequired one. + * + * The usable-emergency-contact count is read BEFORE revokeSuite() because the + * revoke event cascade clears those envelopes; it is threaded into the audit + * metadata and surfaced as an informational warning, never as a gate (unlike + * the owner path's acceptEmergencyLoss). Only the count crosses the wire — the + * contacts' identities stay grantor-private. + * + * @param string $id The suite ID + * @param string $reason The required, free-form revocation reason + * @param bool $markCompromised Treat the suite's secrets as compromised (default false) + * + * @AuthorizedAdminSetting(AdminSettings::class) + * + * @return JSONResponse + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $markCompromised is the + * administrator's explicit, transient compromise decision carried in the + * POST body and bound by name by the router (ADR-005), not a mode switch: + * it only drives the compromise cascade branch on the revoke event. + * + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + */ + #[AuthorizedAdminSetting(AdminSettings::class)] + #[PasswordConfirmationRequired] + public function forceRevoke(string $id, string $reason, bool $markCompromised = false): JSONResponse { + $admin = $this->userSession->getUser(); + if ($admin === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + $adminUid = $admin->getUID(); + + if (trim($reason) === '') { + return new JSONResponse( + data: ['message' => 'A non-empty reason is required'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + // Read BEFORE revokeSuite(): the EncryptionSuiteRevokedEvent cascade + // clears the grantor's emergency envelopes, so the usable count is + // non-zero here only while the contacts still exist. + $emergencyCount = $this->emergencyService->countUsableForGrantorSuite($id); + + $suite = $this->suiteService->revokeSuite( + id: $id, + reason: $reason, + revokedBy: $adminUid, + markCompromised: $markCompromised, + emergencyContactsDestroyed: $emergencyCount, + ); + + $data = $suite->jsonSerialize(); + $data['emergencyContactsDestroyed'] = $emergencyCount; + if ($markCompromised === false) { + $data['warning'] = 'The revoked user may still know these secrets; consider rotating them.'; + } + + return new JSONResponse(data: $data); + } catch (RuntimeException $e) { + return new JSONResponse( + data: ['message' => $e->getMessage()], + statusCode: Http::STATUS_FORBIDDEN + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['message' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end try + }//end forceRevoke() + /** * Initiate compromise recovery: create new suite and migration record. * @@ -342,6 +496,11 @@ public function reinstate(string $id): JSONResponse { * @spec openspec/changes/implement-link-sharing/tasks.md#5.2 */ #[NoAdminRequired] + #[VaultKeyProofRequired( + binds: ['publicKey', 'encryptedPrivateKey'], + subject: 'active', + purpose: VaultKeyProofService::PURPOSE_COMPROMISE_RECOVERY + )] public function compromiseRecovery( string $publicKey, string $encryptedPrivateKey, @@ -442,6 +601,52 @@ public function compromiseRecovery( }//end try }//end compromiseRecovery() + /** + * Issue a vault-key-proof challenge for one of the guarded operations. + * + * Returns a stateless, expiring nonce the client signs with its suite + * private key to authorise a destructive operation. Requires only a session + * and that the caller own the named suite; it is NOT itself guarded, since a + * challenge grants nothing on its own. + * + * @param string $id The caller's suite the proof will be made with + * @param string|null $purpose The operation the challenge authorises + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + */ + #[NoAdminRequired] + public function proofChallenge(string $id, ?string $purpose = null): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + if ($purpose === null || in_array($purpose, VaultKeyProofService::ALLOWED_PURPOSES, true) === false) { + return new JSONResponse( + data: ['message' => 'Unknown or missing proof purpose'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + $suite = $this->suiteService->getSuite($id); + $this->validateOwnership(suite: $suite); + } catch (Exception $e) { + return new JSONResponse( + data: ['message' => $e->getMessage()], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + return new JSONResponse( + data: $this->proofService->issueChallenge(userId: $user->getUID(), purpose: $purpose) + ); + }//end proofChallenge() + /** * Validate that the current user owns the suite. * diff --git a/lib/Controller/MigrationController.php b/lib/Controller/MigrationController.php index 993ddfdd1..ddb4d548c 100644 --- a/lib/Controller/MigrationController.php +++ b/lib/Controller/MigrationController.php @@ -22,14 +22,19 @@ namespace OCA\Keepiq\Controller; use Exception; +use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Attribute\VaultKeyProofRequired; use OCA\Keepiq\Db\SuiteMigration; use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\MigrationAbortRefusedException; use OCA\Keepiq\Exception\MigrationIncompleteException; use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Service\EmergencyEnvelopeInvalidationService; use OCA\Keepiq\Service\EncryptionSuiteService; use OCA\Keepiq\Service\MigrationService; use OCA\Keepiq\Service\MigrationWorkService; +use OCA\Keepiq\Service\VaultKeyProofService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -41,11 +46,19 @@ /** * Controller for suite migration tracking. * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The four migration-work - * endpoints share one guard shell and one exception-to-status mapping, so the - * controller references the migration entity, both guard exceptions and the - * two services. Splitting the stores across controllers would duplicate the - * ownership guard four times over. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The migration-work endpoints + * share one guard shell and one exception-to-status mapping, so the controller + * references the migration entity, the guard exceptions and the work services. + * Splitting the stores across controllers would duplicate the ownership guard + * once per store. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Same cause: one controller + * deliberately holds every per-record migration-work endpoint (secrets, + * versions, attachment grants, emergency contacts) plus status/complete/abort, + * because they all authorise through the same private requireOwnMigration + * guard. The aggregate complexity is the sum of small, uniform endpoints, not a + * single tangled method; dispersing them to satisfy the threshold would copy + * the guard into each new controller — the very IDOR risk the shared shell + * exists to prevent. */ class MigrationController extends OCSController { /** @@ -55,6 +68,7 @@ class MigrationController extends OCSController { * @param MigrationService $migrationService The migration service * @param MigrationWorkService $workService The per-record migration work service * @param EncryptionSuiteService $suiteService The suite service (ownership check) + * @param EmergencyEnvelopeInvalidationService $envelopeService The emergency-envelope re-point service * @param IUserSession $userSession The user session * * @return void @@ -64,6 +78,7 @@ public function __construct( private MigrationService $migrationService, private MigrationWorkService $workService, private EncryptionSuiteService $suiteService, + private EmergencyEnvelopeInvalidationService $envelopeService, private IUserSession $userSession, ) { parent::__construct(appName: Application::APP_ID, request: $request); @@ -114,6 +129,11 @@ public function getStatus(): JSONResponse { * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-4 */ #[NoAdminRequired] + #[VaultKeyProofRequired( + binds: ['id', 'hasErrors', 'acceptUnrecoverable'], + subject: 'migrationOldSuite', + purpose: VaultKeyProofService::PURPOSE_COMPLETE_MIGRATION + )] public function complete(string $id, bool $hasErrors = false, ?int $acceptUnrecoverable = null): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { @@ -172,6 +192,55 @@ public function complete(string $id, bool $hasErrors = false, ?int $acceptUnreco }//end try }//end complete() + /** + * Abort a migration, returning the vault to the old suite. + * + * The endpoint the `compromiseRecovery` refusal already tells users to use. + * Non-destructive: it discards the unused successor and leaves the old suite + * active. Permitted only while no record has been committed to the new suite; + * once records have moved the server refuses and points at resuming. + * + * @param string $id The migration ID + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/encryption-suites/spec.md#requirement-a-migration-can-be-aborted-before-any-record-moves + */ + #[NoAdminRequired] + public function abort(string $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $this->requireOwnMigration(migrationId: $id, userId: $user->getUID()); + + $result = $this->migrationService->abortMigration(migrationId: $id); + return new JSONResponse(data: $result); + } catch (ForbiddenException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); + } catch (MigrationAbortRefusedException $e) { + // The migration is intact and resumable — a record has already + // moved, so abort would lose data. Distinct from a generic fault so + // the client offers "resume", not "try abort again". + return new JSONResponse( + data: [ + 'error' => 'migration_abort_refused', + 'message' => $e->getMessage(), + 'committed' => $e->getCommitted(), + ], + statusCode: Http::STATUS_CONFLICT + ); + } catch (NotFoundException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_NOT_FOUND); + } catch (Exception $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + }//end try + }//end abort() + /** * List the records still bound to the migration's old suite. * @@ -418,6 +487,85 @@ public function reEncryptAttachmentGrant( ); }//end reEncryptAttachmentGrant() + /** + * Re-point one emergency-access recovery envelope onto the new suite. + * + * Emergency contacts are the one migrated store not produced by + * decrypt-then-re-encrypt: the browser builds a fresh envelope escrowing the + * NEW private key, sealed to the grantee's current certificate, and posts it + * here. Deliberately NOT routed through commitRecord: emergency contacts are + * outside the completion gate (design D2), so there is no per-record failure + * to account and a contact the browser could not carry is simply left on the + * old suite for the completion sweep to invalidate — never recorded as a + * migration failure that would block the gate. + * + * @param string $id The migration ID + * @param string $contactId The emergency-contact ID + * @param string|null $recoveryEnvelope The fresh envelope escrowing the new private key + * @param string|null $granteeSuiteId The grantee suite the envelope was sealed to + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/migrate-emergency-access-on-rotation/specs/encryption-suites/spec.md#requirement-migration-covers-every-suite-bound-store + */ + #[NoAdminRequired] + public function reEnvelopeEmergencyContact( + string $id, + string $contactId, + ?string $recoveryEnvelope = null, + ?string $granteeSuiteId = null, + ): JSONResponse { + $userId = $this->uid(); + if ($userId === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + if ($recoveryEnvelope === null || $granteeSuiteId === null) { + return new JSONResponse( + data: ['message' => 'A recovery envelope and grantee suite are required'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + $migration = $this->requireOwnMigration(migrationId: $id, userId: $userId); + + // Re-pointing to the new suite only makes sense while the migration + // owns the write lock; once terminated the sweep has already run. + if ($migration->getStatus() !== 'in_progress') { + return new JSONResponse( + data: ['message' => 'Migration is no longer in progress'], + statusCode: Http::STATUS_CONFLICT + ); + } + + $contact = $this->envelopeService->reEnvelopeForRotation( + ownerId: $userId, + oldSuiteId: $migration->getOldSuiteId(), + newSuiteId: $migration->getNewSuiteId(), + contactId: $contactId, + recoveryEnvelope: $recoveryEnvelope, + sealedSuiteId: $granteeSuiteId + ); + + return new JSONResponse( + data: [ + 'id' => $contact->getId(), + 'grantorSuiteId' => $contact->getGrantorSuiteId(), + 'state' => $contact->getState(), + ] + ); + } catch (NotFoundException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_NOT_FOUND); + } catch (ForbiddenException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + }//end try + }//end reEnvelopeEmergencyContact() + /** * The acting user's id, or null when unauthenticated. * diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 80d803aae..694c60e93 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -23,6 +23,7 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\Connection\ConnectionReporter; use OCA\Keepiq\Service\SettingsService; use OCA\Keepiq\Settings\AdminSettings; use OCP\AppFramework\Controller; @@ -43,13 +44,17 @@ class SettingsController extends Controller { * @param IRequest $request The request object * @param SettingsService $settingsService The settings service * @param IUserSession $userSession The user session + * @param ConnectionReporter|null $connectionReporter Asks integriq to look again after a breach check save, or nothing when absent. * * @return void + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met */ public function __construct( IRequest $request, private SettingsService $settingsService, private IUserSession $userSession, + private ?ConnectionReporter $connectionReporter = null, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -192,11 +197,16 @@ public function getAdminSettings(): JSONResponse { /** * Update admin-scoped settings (implement-dashboard-settings §2.2). * + * A save that wrote `breach_check_enabled` asks integriq to resolve the + * breach check connection again (adopt-connection-registry). That never + * throws, does nothing without integriq, and never changes the response. + * * @AuthorizedAdminSetting(AdminSettings::class) * * @return JSONResponse * * @spec openspec/changes/implement-dashboard-settings/tasks.md#task-2.2 + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met */ #[AuthorizedAdminSetting(AdminSettings::class)] public function updateAdminSettings(): JSONResponse { @@ -211,6 +221,11 @@ public function updateAdminSettings(): JSONResponse { ); } + // The same test AdminSettingsService uses to decide it wrote the key. + if (isset($data['breach_check_enabled']) === true) { + $this->connectionReporter?->breachCheckSaved(); + } + return new JSONResponse(data: $result); }//end updateAdminSettings() diff --git a/lib/Db/EmergencyContact.php b/lib/Db/EmergencyContact.php index a58aac477..3f74ae69f 100644 --- a/lib/Db/EmergencyContact.php +++ b/lib/Db/EmergencyContact.php @@ -217,6 +217,7 @@ public function jsonSerialize(): array { 'accessLevel' => $this->accessLevel, 'waitPeriodDays' => $this->waitPeriodDays, 'state' => $this->state, + 'grantorSuiteId' => $this->grantorSuiteId, 'requestedAt' => $this->requestedAt?->format('c'), 'hasEnvelope' => ($this->recoveryEnvelope !== null && $this->recoveryEnvelope !== ''), 'createdAt' => $this->createdAt?->format('c'), diff --git a/lib/Event/Audit/AuditEventTypes.php b/lib/Event/Audit/AuditEventTypes.php index 329b1ed7a..4b3431078 100644 --- a/lib/Event/Audit/AuditEventTypes.php +++ b/lib/Event/Audit/AuditEventTypes.php @@ -214,7 +214,7 @@ final class AuditEventTypes { // event type itself. Adding expires_at here would put a timestamp in the // trail that the request row already carries. self::REQUEST_EXPIRED => [], - self::SUITE_REVOKED => ['reason'], + self::SUITE_REVOKED => ['reason', 'markCompromised', 'emergencyContactsDestroyed'], self::SUITE_REINSTATED => [], self::SUITE_RECOVERY_STARTED => [], self::SUITE_RECOVERY_COMPLETED => ['reSuitedCount'], diff --git a/lib/Event/EncryptionSuiteRevokedEvent.php b/lib/Event/EncryptionSuiteRevokedEvent.php index 2a01e5116..cd1d28dac 100644 --- a/lib/Event/EncryptionSuiteRevokedEvent.php +++ b/lib/Event/EncryptionSuiteRevokedEvent.php @@ -36,14 +36,20 @@ class EncryptionSuiteRevokedEvent extends Event { * @param string $ownerType The owner type ('user' or 'application') * @param string $ownerId The owner Nextcloud user ID or application ID * @param string $revokedBy The user that triggered the revocation + * @param bool $compromised Whether the revocation treats the suite as compromised * * @return void + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $compromised carries the + * administrator's explicit, transient compromise decision (ADR-005); it is + * a payload field on the event, not a mode switch between two behaviours. */ public function __construct( private string $suiteId, private string $ownerType, private string $ownerId, private string $revokedBy, + private bool $compromised = false, ) { parent::__construct(); }//end __construct() @@ -83,4 +89,17 @@ public function getOwnerId(): string { public function getRevokedBy(): string { return $this->revokedBy; }//end getRevokedBy() + + /** + * Whether this revocation treats the suite's secrets as compromised. + * + * @return bool + * + * @SuppressWarnings(PHPMD.BooleanGetMethodName) The accessor mirrors the + * event's other get* getters and its callers/tests read it as + * getCompromised(); the flag is a plain payload field (admin-suite-revocation). + */ + public function getCompromised(): bool { + return $this->compromised; + }//end getCompromised() }//end class diff --git a/lib/Event/SuiteMigrationAbortedEvent.php b/lib/Event/SuiteMigrationAbortedEvent.php new file mode 100644 index 000000000..78dafb163 --- /dev/null +++ b/lib/Event/SuiteMigrationAbortedEvent.php @@ -0,0 +1,79 @@ + + * @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\Event; + +use OCP\EventDispatcher\Event; + +/** + * Fired when a compromise-recovery migration is aborted with nothing migrated. + */ +class SuiteMigrationAbortedEvent extends Event { + /** + * Constructor. + * + * @param string $oldSuiteId The suite the vault returns to (still active) + * @param string $newSuiteId The discarded successor suite's id + * @param string $migrationId The aborted migration's id + * + * @return void + */ + public function __construct( + private string $oldSuiteId, + private string $newSuiteId, + private string $migrationId, + ) { + parent::__construct(); + }//end __construct() + + /** + * The suite the vault returns to. + * + * @return string + */ + public function getOldSuiteId(): string { + return $this->oldSuiteId; + }//end getOldSuiteId() + + /** + * The discarded successor suite's id. + * + * @return string + */ + public function getNewSuiteId(): string { + return $this->newSuiteId; + }//end getNewSuiteId() + + /** + * The aborted migration's id. + * + * @return string + */ + public function getMigrationId(): string { + return $this->migrationId; + }//end getMigrationId() +}//end class diff --git a/lib/Exception/KeyProofRequiredException.php b/lib/Exception/KeyProofRequiredException.php new file mode 100644 index 000000000..6f4f54401 --- /dev/null +++ b/lib/Exception/KeyProofRequiredException.php @@ -0,0 +1,35 @@ + + * @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\Exception; + +use RuntimeException; + +/** + * Thrown when a required vault-key proof is absent or does not verify. + */ +class KeyProofRequiredException extends RuntimeException { +}//end class diff --git a/lib/Exception/MigrationAbortRefusedException.php b/lib/Exception/MigrationAbortRefusedException.php new file mode 100644 index 000000000..43eef89f9 --- /dev/null +++ b/lib/Exception/MigrationAbortRefusedException.php @@ -0,0 +1,62 @@ + + * @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\Exception; + +use RuntimeException; + +/** + * Thrown when a migration has moved records and can no longer be aborted. + */ +class MigrationAbortRefusedException extends RuntimeException { + /** + * How many records have already been committed to the new suite. + * + * @var integer + */ + private int $committed = 0; + + /** + * Record the committed count to surface to the caller. + * + * @param integer $committed The number of records already on the new suite + * + * @return self + */ + public function withCommitted(int $committed): self { + $this->committed = $committed; + return $this; + }//end withCommitted() + + /** + * The number of records already committed to the new suite. + * + * @return integer + */ + public function getCommitted(): int { + return $this->committed; + }//end getCommitted() +}//end class diff --git a/lib/Listener/EmergencyAccessSuiteRotationListener.php b/lib/Listener/EmergencyAccessSuiteRotationListener.php index 06af60f1d..cf8087c63 100644 --- a/lib/Listener/EmergencyAccessSuiteRotationListener.php +++ b/lib/Listener/EmergencyAccessSuiteRotationListener.php @@ -71,7 +71,15 @@ public function handle(Event $event): void { } try { - // The envelope escrows the OLD suite's private key. + // Residual SWEEP, not a blanket invalidation. The migration loop has + // already re-enveloped every reachable contact onto the new suite + // (MigrationController::reEnvelopeEmergencyContact), so those rows no + // longer sit on the old suite and this pass skips them. What remains on + // the old suite is exactly the residual the browser could not carry — + // a grantee with no active certificate to seal to — which genuinely + // must be invalidated. Do NOT "optimise away" this apparent no-op: on a + // rotation with an unreachable grantee it is the only thing that clears + // the stale envelope. The envelope escrows the OLD suite's private key. $this->service->invalidateForGrantorRotation( grantorSuiteId: $event->getOldSuiteId(), reason: 'grantor_rotation', diff --git a/lib/Listener/SuiteCompromiseOnRevokeListener.php b/lib/Listener/SuiteCompromiseOnRevokeListener.php new file mode 100644 index 000000000..4fdd23ccf --- /dev/null +++ b/lib/Listener/SuiteCompromiseOnRevokeListener.php @@ -0,0 +1,175 @@ + + * @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\Listener; + +use DateTime; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Event\EncryptionSuiteRevokedEvent; +use OCA\Keepiq\Service\NotificationService; +use OCA\Keepiq\Service\RotationPolicyService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Run the compromise cascade over a revoked suite's blast radius. + * + * @implements IEventListener + * + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + */ +class SuiteCompromiseOnRevokeListener implements IEventListener { + /** + * Constructor. + * + * @param SecretMapper $secretMapper The Secret mapper (blast-radius lookup + stamp) + * @param ShareTargetMapper $shareTargetMapper The share-target mapper (resolve owners) + * @param NotificationService $notificationService The notification dispatcher + * @param LoggerInterface $logger The logger + * @param RotationPolicyService|null $rotationService The rotation service (auto-flag) + * + * @return void + */ + public function __construct( + private SecretMapper $secretMapper, + private ShareTargetMapper $shareTargetMapper, + private NotificationService $notificationService, + private LoggerInterface $logger, + private ?RotationPolicyService $rotationService = null, + ) { + }//end __construct() + + /** + * Handle the EncryptionSuiteRevokedEvent. + * + * Only reacts when the revocation was flagged as a compromise; the owner + * path leaves the flag false and this listener is a no-op there — the whole + * cascade is gated on the administrator's explicit decision (ADR-005 D2). + * + * @param Event $event The dispatched event + * + * @return void + * + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + */ + public function handle(Event $event): void { + if ($event instanceof EncryptionSuiteRevokedEvent === false) { + return; + } + + if ($event->getCompromised() === false) { + // Not a compromise revocation (the owner path, or an administrator + // who left markCompromised off) — no cascade runs. + return; + } + + try { + $notified = []; + // Every Secret sealed under the revoked suite is in the blast + // radius. Unlike the migration path, nothing has stamped + // possibly_compromised_at yet, so this listener stamps it here. + $secrets = $this->secretMapper->findByEncryptionSuiteId($event->getSuiteId()); + foreach ($secrets as $secret) { + if ($secret->getPossiblyCompromisedAt() === null) { + $secret->setPossiblyCompromisedAt(new DateTime()); + $this->secretMapper->update($secret); + } + + // Auto-raise a rotation flag per compromised secret + // (rotation-expiry-policies §3.2; idempotent). + $this->rotationService?->flag( + secretId: $secret->getId(), + reason: 'suite_compromise' + ); + + $ownerId = $this->resolveSourceOwner( + recipientSecretId: $secret->getId(), + fallbackOwnerId: $secret->getOwnerId() + ); + + if ($ownerId === '' || isset($notified[$ownerId]) === true) { + continue; + } + + $this->notificationService->notify( + subject: 'secret_compromised', + recipientId: $ownerId, + params: [ + 'suiteId' => $event->getSuiteId(), + 'revokedBy' => $event->getRevokedBy(), + 'secretId' => $secret->getId(), + 'secretName' => $secret->getName(), + ], + objectType: 'secret', + objectId: $secret->getId(), + ); + $notified[$ownerId] = true; + }//end foreach + } catch (Throwable $exception) { + $this->logger->warning( + 'Keepiq: SuiteCompromiseOnRevokeListener failed: ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + }//end try + }//end handle() + + /** + * Resolve a recipient Secret copy back to its source owner via the + * ShareTarget mapper. If the copy is not part of any share (a direct + * owner copy), fall back to the copy's own owner. + * + * @param string $recipientSecretId The recipient Secret ID + * @param string $fallbackOwnerId The fallback owner + * + * @return string + */ + private function resolveSourceOwner( + string $recipientSecretId, + string $fallbackOwnerId, + ): string { + try { + $row = $this->shareTargetMapper->findByRecipientSecret( + recipientSecretId: $recipientSecretId + ); + try { + $source = $this->secretMapper->findById($row->getSourceSecretId()); + return $source->getOwnerId(); + } catch (DoesNotExistException) { + return $fallbackOwnerId; + } + } catch (DoesNotExistException) { + // Not a shared copy — fall back to the secret's own owner. + return $fallbackOwnerId; + } catch (Throwable) { + return $fallbackOwnerId; + } + }//end resolveSourceOwner() +}//end class diff --git a/lib/Listener/SuiteMigrationAbortedListener.php b/lib/Listener/SuiteMigrationAbortedListener.php new file mode 100644 index 000000000..867563535 --- /dev/null +++ b/lib/Listener/SuiteMigrationAbortedListener.php @@ -0,0 +1,97 @@ + + * @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\Listener; + +use OCA\Keepiq\Event\SuiteMigrationAbortedEvent; +use OCA\Keepiq\Service\SecretRequestSuiteLockService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Unlock SecretRequests, keeping the old suite, when a migration is aborted. + * + * @implements IEventListener + */ +class SuiteMigrationAbortedListener implements IEventListener { + /** + * Constructor. + * + * @param SecretRequestSuiteLockService $secretRequestService The SecretRequest suite-lock service + * @param LoggerInterface $logger The logger + * + * @return void + */ + public function __construct( + private SecretRequestSuiteLockService $secretRequestService, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Handle the event. + * + * @param Event $event The event + * + * @return void + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/encryption-suites/spec.md#requirement-a-migration-can-be-aborted-before-any-record-moves + */ + public function handle(Event $event): void { + if (($event instanceof SuiteMigrationAbortedEvent) === false) { + return; + } + + try { + // Unlock the requests locked at start, keeping them on the OLD + // suite: passing the old id as both arguments re-points them to the + // suite they are already on (a no-op update) and flips their status + // back to pending. The new suite is being discarded, so it must not + // become their target. + $unlocked = $this->secretRequestService->unlockAndUpdateSuite( + $event->getOldSuiteId(), + $event->getOldSuiteId() + ); + $this->logger->info( + 'Keepiq: unlocked SecretRequests after migration abort, kept on the old suite', + [ + 'oldSuiteId' => $event->getOldSuiteId(), + 'unlocked' => $unlocked, + ] + ); + } catch (Throwable $e) { + $this->logger->error( + 'Keepiq: SuiteMigrationAbortedListener failed: ' . $e->getMessage(), + ['exception' => $e] + ); + } + }//end handle() +}//end class diff --git a/lib/Middleware/VaultKeyProofMiddleware.php b/lib/Middleware/VaultKeyProofMiddleware.php new file mode 100644 index 000000000..1d7455ef8 --- /dev/null +++ b/lib/Middleware/VaultKeyProofMiddleware.php @@ -0,0 +1,257 @@ + + * @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\Middleware; + +use OCA\Keepiq\Attribute\VaultKeyProofRequired; +use OCA\Keepiq\Db\EncryptionSuite; +use OCA\Keepiq\Db\SuiteMigrationMapper; +use OCA\Keepiq\Exception\KeyProofRequiredException; +use OCA\Keepiq\Service\EncryptionSuiteService; +use OCA\Keepiq\Service\VaultKeyProofService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Middleware; +use OCP\IRequest; +use OCP\IUserSession; +use ReflectionMethod; +use Throwable; + +/** + * Enforce #[VaultKeyProofRequired] on the annotated controller methods. + */ +class VaultKeyProofMiddleware extends Middleware { + /** + * The header carrying the base64 signature. + */ + private const HEADER_PROOF = 'X-Keepiq-Key-Proof'; + + /** + * The header echoing the challenge the proof was made over. + */ + private const HEADER_NONCE = 'X-Keepiq-Key-Proof-Nonce'; + + /** + * Constructor. + * + * @param IRequest $request The HTTP request + * @param IUserSession $userSession The session, for the acting user + * @param EncryptionSuiteService $suiteService Resolves the subject suite + * @param VaultKeyProofService $proofService Verifies the proof + * @param SuiteMigrationMapper $migrationMapper Resolves a migration's old suite + * + * @return void + */ + public function __construct( + private IRequest $request, + private IUserSession $userSession, + private EncryptionSuiteService $suiteService, + private VaultKeyProofService $proofService, + private SuiteMigrationMapper $migrationMapper, + ) { + }//end __construct() + + /** + * Verify the proof before a guarded method runs. + * + * @param Controller $controller The controller about to run + * @param string $methodName The method about to run + * + * @return void + * + * @throws KeyProofRequiredException When the guard is not satisfied + */ + public function beforeController($controller, $methodName): void { + $attribute = $this->attributeFor(controller: $controller, methodName: $methodName); + if ($attribute === null) { + return; + } + + $user = $this->userSession->getUser(); + if ($user === null) { + // No session at all is an authentication problem, not a proof one; + // the framework's own auth handling has already refused, but guard + // against a null here rather than dereferencing it. + throw new KeyProofRequiredException(message: 'Not authenticated'); + } + + $userId = $user->getUID(); + $certificate = $this->subjectCertificate(attribute: $attribute, userId: $userId); + + $boundValues = []; + foreach ($attribute->getBinds() as $name) { + $boundValues[] = (string)$this->request->getParam($name, ''); + } + + $this->proofService->verify( + nonce: $this->request->getHeader(self::HEADER_NONCE), + signatureB64: $this->request->getHeader(self::HEADER_PROOF), + certificatePem: $certificate, + userId: $userId, + purpose: $attribute->getPurpose(), + boundValues: $boundValues, + ); + }//end beforeController() + + /** + * Translate a failed guard into a 403 the client can act on. + * + * @param Controller $controller The controller + * @param string $methodName The method + * @param Throwable $exception The raised exception + * + * @return JSONResponse + * + * @throws Throwable When the exception is not the guard's own (re-thrown) + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $controller and $methodName + * are mandated by OCP\AppFramework\Middleware::afterException(), which this + * overrides; only the exception is acted on. + */ + public function afterException($controller, $methodName, Throwable $exception): JSONResponse { + if (($exception instanceof KeyProofRequiredException) === false) { + throw $exception; + } + + return new JSONResponse( + data: [ + 'error' => 'key_proof_required', + 'message' => $exception->getMessage(), + ], + statusCode: Http::STATUS_FORBIDDEN + ); + }//end afterException() + + /** + * The #[VaultKeyProofRequired] attribute on the method, or null. + * + * @param Controller $controller The controller + * @param string $methodName The method + * + * @return VaultKeyProofRequired|null + */ + private function attributeFor($controller, string $methodName): ?VaultKeyProofRequired { + $reflection = new ReflectionMethod($controller, $methodName); + $attributes = $reflection->getAttributes(VaultKeyProofRequired::class); + if ($attributes === []) { + return null; + } + + return $attributes[0]->newInstance(); + }//end attributeFor() + + /** + * Resolve the certificate whose public key verifies the proof. + * + * @param VaultKeyProofRequired $attribute The guard declaration + * @param string $userId The acting user + * + * @return string The subject suite's certificate PEM + * + * @throws KeyProofRequiredException When the subject suite cannot be resolved + */ + private function subjectCertificate(VaultKeyProofRequired $attribute, string $userId): string { + $subject = $attribute->getSubject(); + + try { + $suite = $this->resolveSubjectSuite(subject: $subject, userId: $userId); + } catch (KeyProofRequiredException $e) { + throw $e; + } catch (Throwable $e) { + throw new KeyProofRequiredException(message: 'No subject suite to verify against'); + } + + $certificate = $suite->getCertificate(); + if ($certificate === null || $certificate === '') { + throw new KeyProofRequiredException(message: 'Subject suite has no certificate'); + } + + return $certificate; + }//end subjectCertificate() + + /** + * Resolve the subject suite from the attribute's declaration. + * + * @param string $subject The subject declaration ('active' or 'routeParam:') + * @param string $userId The acting user + * + * @return EncryptionSuite + * + * @throws KeyProofRequiredException When a named suite is not the caller's own + */ + private function resolveSubjectSuite(string $subject, string $userId): EncryptionSuite { + if ($subject === 'migrationOldSuite') { + // Completion proves the OLD key, not the new one: at completion both + // suites are active so 'active' is ambiguous, and the old key is the + // one both the initiate and resume clients already hold the password + // for. Resolve it from the migration named by the route's `id`. + $migration = $this->migrationMapper->findById((string)$this->request->getParam('id', '')); + return $this->assertOwned( + suite: $this->suiteService->getSuite($migration->getOldSuiteId()), + userId: $userId + ); + } + + if (str_starts_with($subject, 'routeParam:') === true) { + $paramName = substr($subject, strlen('routeParam:')); + return $this->assertOwned( + suite: $this->suiteService->getSuite((string)$this->request->getParam($paramName, '')), + userId: $userId + ); + } + + return $this->assertOwned( + suite: $this->suiteService->getActiveSuite(ownerType: 'user', ownerId: $userId), + userId: $userId + ); + }//end resolveSubjectSuite() + + /** + * Assert the resolved suite is the caller's own; a proof is always over the + * owner's key, never another user's or an application's. + * + * @param EncryptionSuite $suite The resolved suite + * @param string $userId The acting user + * + * @return EncryptionSuite + * + * @throws KeyProofRequiredException When the suite is not the caller's + */ + private function assertOwned(EncryptionSuite $suite, string $userId): EncryptionSuite { + if ($suite->getOwnerType() !== 'user' || $suite->getOwnerId() !== $userId) { + throw new KeyProofRequiredException(message: 'Subject suite is not yours'); + } + + return $suite; + }//end assertOwned() +}//end class diff --git a/lib/Service/Connection/ConnectionObservations.php b/lib/Service/Connection/ConnectionObservations.php new file mode 100644 index 000000000..685baf2a7 --- /dev/null +++ b/lib/Service/Connection/ConnectionObservations.php @@ -0,0 +1,240 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Service\Connection; + +use Throwable; + +/** + * Maps outcomes to connection statuses and messages. + * + * Every message is built from fixed text, a number and a host. None of them + * takes a string a user typed, an exception message or a full URL: a Guzzle + * exception names the request URL, and on a range lookup that URL ends in the + * caller's hash prefix. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-003-a-report-names-a-status-code-or-a-host-and-nothing-a-user-typed + */ +class ConnectionObservations { + + /** + * The HTTP status that says the other side limited the call. + * + * @var int + */ + public const RATE_LIMITED_STATUS = 429; + + /** + * What one Have I Been Pwned range lookup says about the connection. + * + * Takes only the HTTP status. The prefix, the suffix list and the + * exception never reach this method, so they cannot reach a message. + * + * @param int|null $httpStatus The upstream's HTTP status, or null when nothing answered. + * + * @return array{0: string, 1: string} The status and the message. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-003-a-report-names-a-status-code-or-a-host-and-nothing-a-user-typed + */ + public function breachLookup(?int $httpStatus): array { + if ($httpStatus === null) { + return ['error', 'The last range lookup got no answer from Have I Been Pwned.']; + } + + if ($httpStatus >= 200 && $httpStatus < 300) { + return ['configured', 'The last range lookup reached Have I Been Pwned.']; + } + + if ($httpStatus === self::RATE_LIMITED_STATUS) { + return ['limited', 'Have I Been Pwned limited the last range lookup (HTTP 429).']; + } + + return ['error', 'Have I Been Pwned answered HTTP ' . $httpStatus . ' on the last range lookup.']; + }//end breachLookup() + + /** + * What a sink create, change or delete says about SIEM export. + * + * Only a state that blocks every delivery is reported. With sinks still + * enabled the refresh stands alone, and the row waits for the next drain. + * + * @param int $enabledSinks How many sinks are enabled after the save. + * @param int $sinks How many sinks exist after the save, enabled or not. + * + * @return array{0: string, 1: string}|null The status and the message, or null. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ + public function siemSinksChanged(int $enabledSinks, int $sinks): ?array { + if ($enabledSinks > 0) { + return null; + } + + return $this->noSinkEnabled(sinks: $sinks); + }//end siemSinksChanged() + + /** + * What one SIEM drain says about SIEM export. + * + * Only the sinks the drain delivered to in this run count. A sink's older + * delivery state may predate a save, and a refresh retires exactly that. + * + * @param int $enabledSinks How many sinks are enabled. + * @param array $delivered Per sink the drain delivered to: its host, and + * whether its last delivery went through. + * @param int $sinks How many sinks exist, enabled or not. + * + * @return array{0: string, 1: string}|null The status and the message, or null when the drain met nothing. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ + public function siemDrain(int $enabledSinks, array $delivered, int $sinks): ?array { + if ($enabledSinks === 0) { + return $this->noSinkEnabled(sinks: $sinks); + } + + $total = count($delivered); + if ($total === 0) { + return null; + } + + $failed = array_values(array_filter($delivered, static fn (array $sink): bool => $sink['ok'] !== true)); + if ($failed === [] && $total === 1) { + return ['configured', 'The SIEM sink' . $this->atHost(host: $delivered[0]['host']) . ' took the last delivery.']; + } + + if ($failed === []) { + return ['configured', 'All ' . $total . ' SIEM sinks took their last delivery.']; + } + + if ($total === 1) { + return ['error', 'The last delivery to the SIEM sink' . $this->atHost(host: $failed[0]['host']) . ' failed.']; + } + + $firstFailure = ''; + if ($failed[0]['host'] !== '') { + $firstFailure = ' The first to fail is at ' . $failed[0]['host'] . '.'; + } + + if (count($failed) === $total) { + return ['error', 'None of the ' . $total . ' SIEM sinks took their last delivery.' . $firstFailure]; + } + + return [ + 'limited', + ($total - count($failed)) . ' of ' . $total . ' SIEM sinks took their last delivery.' . $firstFailure, + ]; + }//end siemDrain() + + /** + * The host of a sink endpoint, and nothing else from it. + * + * A webhook endpoint is an https URL. A syslog endpoint is `host:port`, so + * it is read behind `tcp://`. A path, a query or user info can carry a + * token, and every admin reads the row. + * + * @param string $endpoint The sink's stored endpoint. + * + * @return string The host, or an empty string when there is none. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-003-a-report-names-a-status-code-or-a-host-and-nothing-a-user-typed + */ + public function siemSinkHost(string $endpoint): string { + $endpoint = trim($endpoint); + if (str_contains($endpoint, '://') === false) { + $endpoint = 'tcp://' . $endpoint; + } + + $host = parse_url($endpoint, PHP_URL_HOST); + if (is_string($host) === false) { + return ''; + } + + return $host; + }//end siemSinkHost() + + /** + * The HTTP status a failed call still carries, or null when nothing answered. + * + * Nextcloud's HTTP client throws on a 4xx or 5xx answer. Guzzle's request + * exceptions keep that answer, and a connection failure has none. + * + * @param Throwable $exception What the call threw. + * + * @return int|null The answer's HTTP status, or null. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-003-a-report-names-a-status-code-or-a-host-and-nothing-a-user-typed + */ + public function httpStatusOf(Throwable $exception): ?int { + if (method_exists($exception, 'getResponse') === false) { + return null; + } + + $response = $exception->getResponse(); + if (is_object($response) === false || method_exists($response, 'getStatusCode') === false) { + return null; + } + + return (int) $response->getStatusCode(); + }//end httpStatusOf() + + /** + * " at {host}", or nothing when the sink has no host. + * + * @param string $host The sink's host, possibly empty. + * + * @return string + */ + private function atHost(string $host): string { + if ($host === '') { + return ''; + } + + return ' at ' . $host; + }//end atHost() + + /** + * The report for an instance where no sink is enabled. + * + * Sinks that exist and are all switched off are a choice an admin made, so + * they read `disabled` (hydra connection-registry D4, D12 item 9). No sink + * at all is a step nobody took yet, so it stays `unconfigured`. Neither + * message names a host: there is no delivery to name one from. + * + * @param int $sinks How many sinks exist, enabled or not. + * + * @return array{0: string, 1: string} + */ + private function noSinkEnabled(int $sinks): array { + if ($sinks > 0) { + return ['disabled', 'Every SIEM sink is switched off, so no audit event is forwarded.']; + } + + return ['unconfigured', 'No SIEM sink is added yet. Add one under SIEM audit export.']; + }//end noSinkEnabled() +}//end class diff --git a/lib/Service/Connection/ConnectionReporter.php b/lib/Service/Connection/ConnectionReporter.php new file mode 100644 index 000000000..276d5d341 --- /dev/null +++ b/lib/Service/Connection/ConnectionReporter.php @@ -0,0 +1,438 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Service\Connection; + +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Db\SiemSink; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Sends connection reports and refresh requests to integriq. + * + * A save refreshes before it reports: under hydra#674 a refresh retires every + * observation older than itself, so a report sent first would be thrown away. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ +class ConnectionReporter { + + /** + * The app id integriq keys the rows by. + * + * @var string + */ + public const APP_ID = Application::APP_ID; + + /** + * Integriq's report event (ADR-041). Named by string so Keepiq stays + * installable without integriq: the class only exists when integriq does. + * + * @var string + */ + public const STATUS_EVENT = 'OCA\Integriq\Event\ConnectionStatusReportedEvent'; + + /** + * Integriq's refresh event. Named by string for the same reason. + * + * @var string + */ + public const REFRESH_EVENT = 'OCA\Integriq\Event\ConnectionRefreshRequestedEvent'; + + /** + * The Have I Been Pwned connection key in lib/Settings/connections.json. + * + * @var string + */ + public const KEY_HIBP = 'hibp'; + + /** + * The SIEM audit export connection key in lib/Settings/connections.json. + * + * @var string + */ + public const KEY_SIEM = 'siem'; + + /** + * The keys `lib/Settings/connections.json` declares, in declared order. + * + * A unit test keeps the two equal. + * + * @var array + */ + public const KEYS = [self::KEY_HIBP, self::KEY_SIEM]; + + /** + * Prefix of the app-config key that remembers the last report per connection. + * + * @var string + */ + public const MEMORY_KEY_PREFIX = 'connection_report_'; + + /** + * Seconds after which the same status is reported again. + * + * @var int + */ + public const REPEAT_SECONDS = 3600; + + /** + * Seconds that must pass before a different status is reported. + * + * @var int + */ + public const CHANGE_SECONDS = 300; + + /** + * The pure outcome mapper. + * + * @var ConnectionObservations + */ + private readonly ConnectionObservations $observations; + + /** + * Constructor. + * + * @param IEventDispatcher $eventDispatcher Sends the integriq events (ADR-041). + * @param IAppConfig $appConfig Keeps the report memory. + * @param ITimeFactory $timeFactory Tells the time for the report memory. + * @param LoggerInterface $logger Records what could not be sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ + public function __construct( + private readonly IEventDispatcher $eventDispatcher, + private readonly IAppConfig $appConfig, + private readonly ITimeFactory $timeFactory, + private readonly LoggerInterface $logger, + ) { + $this->observations = new ConnectionObservations(); + }//end __construct() + + /** + * After an admin save wrote `breach_check_enabled`: ask integriq to look again. + * + * No report follows. Integriq reads the `hibp` switch itself (rule 2b), and + * a lookup reports once a user checks a password. + * + * @return bool True when the refresh was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ + public function breachCheckSaved(): bool { + return $this->refresh(key: self::KEY_HIBP); + }//end breachCheckSaved() + + /** + * Report what one range lookup to Have I Been Pwned met. + * + * @param int|null $httpStatus The upstream's HTTP status, or null when nothing answered. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-003-a-report-names-a-status-code-or-a-host-and-nothing-a-user-typed + */ + public function reportBreachLookup(?int $httpStatus): bool { + return $this->reportObserved( + key: self::KEY_HIBP, + observe: fn (): array => $this->observations->breachLookup(httpStatus: $httpStatus) + ); + }//end reportBreachLookup() + + /** + * After a sink create, change or delete: refresh, then report when no sink is left on. + * + * The counts are only taken when integriq is installed, so without it the + * save costs no extra query. All sinks are only counted when none is + * enabled, to tell switched off from never added. + * + * @param callable(): int $enabledSinkCount Counts the sinks that are enabled after the save. + * @param callable(): int $sinkCount Counts every sink after the save, enabled or not. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ + public function siemSinksChanged(callable $enabledSinkCount, callable $sinkCount): bool { + if ($this->refresh(key: self::KEY_SIEM) === false) { + return false; + } + + return $this->reportObserved( + key: self::KEY_SIEM, + observe: function () use ($enabledSinkCount, $sinkCount): ?array { + $enabled = $enabledSinkCount(); + + return $this->observations->siemSinksChanged( + enabledSinks: $enabled, + sinks: $this->countSinksWhenNoneEnabled(enabled: $enabled, sinkCount: $sinkCount) + ); + } + ); + }//end siemSinksChanged() + + /** + * Report what one SIEM drain met. + * + * @param int $enabledSinks How many sinks are enabled. + * @param array $attemptedSinks The sinks this drain tried to deliver to, after the attempt. + * @param callable(): int $sinkCount Counts every sink, enabled or not. Only called when none is enabled. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ + public function reportSiemDrain(int $enabledSinks, array $attemptedSinks, callable $sinkCount): bool { + return $this->reportObserved( + key: self::KEY_SIEM, + observe: fn (): ?array => $this->observations->siemDrain( + enabledSinks: $enabledSinks, + delivered: array_map( + fn (SiemSink $sink): array => [ + 'host' => $this->observations->siemSinkHost(endpoint: $sink->getEndpoint()), + 'ok' => $sink->getLastDeliveryStatus() === 'ok', + ], + array_values($attemptedSinks) + ), + sinks: $this->countSinksWhenNoneEnabled(enabled: $enabledSinks, sinkCount: $sinkCount) + ) + ); + }//end reportSiemDrain() + + /** + * Every sink, counted only when none is enabled; otherwise the enabled count stands in. + * + * With a sink enabled the total cannot change the report, so the query is skipped. + * + * @param int $enabled How many sinks are enabled. + * @param callable(): int $sinkCount Counts every sink. + * + * @return int + */ + private function countSinksWhenNoneEnabled(int $enabled, callable $sinkCount): int { + if ($enabled > 0) { + return $enabled; + } + + return $sinkCount(); + }//end countSinksWhenNoneEnabled() + + /** + * The HTTP status a failed call still carries, for {@see reportBreachLookup()}. + * + * Pure: reads, stores and sends nothing. + * + * @param Throwable $exception What the call threw. + * + * @return int|null The answer's HTTP status, or null when nothing answered. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-003-a-report-names-a-status-code-or-a-host-and-nothing-a-user-typed + */ + public function httpStatusOf(Throwable $exception): ?int { + return $this->observations->httpStatusOf(exception: $exception); + }//end httpStatusOf() + + /** + * The event class to instantiate, or null when integriq does not ship it. + * + * @param string $eventClass The fully qualified class name, without a leading backslash. + * + * @return string|null The class name to instantiate, or null when absent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ + protected function resolveEventClass(string $eventClass): ?string { + $qualified = '\\' . $eventClass; + if (class_exists($qualified) === false) { + return null; + } + + return $qualified; + }//end resolveEventClass() + + /** + * Ask integriq to resolve one connection again, and forget its report memory. + * + * Forgetting lets the first outcome after a save go out at once instead of + * waiting out the hour. Never throws. + * + * @param string $key One of {@see self::KEYS}. + * + * @return bool True when the event was dispatched. + */ + private function refresh(string $key): bool { + $eventClass = $this->resolveEventClass(eventClass: self::REFRESH_EVENT); + if ($eventClass === null) { + return false; + } + + $this->forget(key: $key); + + return $this->send( + key: $key, + build: static fn (): object => new $eventClass( + app: self::APP_ID, + key: $key, + ) + ); + }//end refresh() + + /** + * Observe, throttle and send one status report. Never throws. + * + * Without integriq the class check fails first, so nothing is read, + * stored, sent or logged. + * + * @param string $key One of {@see self::KEYS}. + * @param callable(): (array{0: string, 1: string}|null) $observe Works out the status and message, or null to report nothing. + * + * @return bool True when a report was sent. + */ + private function reportObserved(string $key, callable $observe): bool { + $eventClass = $this->resolveEventClass(eventClass: self::STATUS_EVENT); + if ($eventClass === null) { + return false; + } + + try { + $observed = $observe(); + if ($observed === null) { + return false; + } + + [$status, $message] = $observed; + + $now = $this->timeFactory->getTime(); + if ($this->isDue(key: $key, status: $status, now: $now) === false) { + return false; + } + + $sent = $this->send( + key: $key, + build: static fn (): object => new $eventClass( + app: self::APP_ID, + key: $key, + status: $status, + message: $message, + ) + ); + if ($sent === true) { + $this->appConfig->setValueString(self::APP_ID, self::MEMORY_KEY_PREFIX . $key, $status . '|' . $now); + } + + return $sent; + } catch (Throwable $e) { + $this->logger->warning( + 'Keepiq: could not report a connection to integriq', + ['key' => $key, 'exception' => $e::class] + ); + return false; + }//end try + }//end reportObserved() + + /** + * Whether the report memory allows a report with this status now. + * + * A different status waits five minutes after the last report, so an + * upstream that flips cannot report on every call. The same status + * reports again after an hour. + * + * @param string $key The connection key. + * @param string $status The status the call observed. + * @param int $now The current Unix time. + * + * @return bool + */ + private function isDue(string $key, string $status, int $now): bool { + $memory = $this->appConfig->getValueString(self::APP_ID, self::MEMORY_KEY_PREFIX . $key, ''); + $parts = explode('|', $memory, 2); + if (count($parts) !== 2 || ctype_digit($parts[1]) === false) { + return true; + } + + $elapsed = ($now - (int) $parts[1]); + if ($parts[0] === $status) { + return $elapsed >= self::REPEAT_SECONDS; + } + + return $elapsed >= self::CHANGE_SECONDS; + }//end isDue() + + /** + * Clear the report memory of one connection. + * + * @param string $key The connection key. + * + * @return void + */ + private function forget(string $key): void { + try { + $this->appConfig->deleteKey(self::APP_ID, self::MEMORY_KEY_PREFIX . $key); + } catch (Throwable $e) { + $this->logger->warning( + 'Keepiq: could not clear a connection report memory', + ['key' => $key, 'exception' => $e::class] + ); + } + }//end forget() + + /** + * Build and dispatch one event, swallowing anything a listener throws. + * + * The log names the exception class only. A listener's message could quote + * the event, and the event is not for the log. + * + * @param string $key The connection the event is about, for the log. + * @param callable(): object $build Builds the event. + * + * @return bool True when the event was dispatched without an exception. + */ + private function send(string $key, callable $build): bool { + try { + $event = $build(); + if (($event instanceof Event) === false) { + return false; + } + + $this->eventDispatcher->dispatchTyped($event); + return true; + } catch (Throwable $e) { + $this->logger->warning( + 'Keepiq: could not send a connection event to integriq', + ['key' => $key, 'exception' => $e::class] + ); + return false; + } + }//end send() +}//end class diff --git a/lib/Service/EmergencyEnvelopeInvalidationService.php b/lib/Service/EmergencyEnvelopeInvalidationService.php index d02b9c3c9..c84cbba25 100644 --- a/lib/Service/EmergencyEnvelopeInvalidationService.php +++ b/lib/Service/EmergencyEnvelopeInvalidationService.php @@ -6,9 +6,16 @@ * Envelope invalidation on key change (add-emergency-access): when a * grantor's or grantee's encryption suite is rotated or revoked, the * grantee-encrypted recovery envelopes that escrow the now-stale key must - * stop being usable. Rotation MARKS the relationships invalid (the grantor - * must re-establish emergency access); revocation of the grantor's suite - * DELETES them outright, because the key they wrap is void. + * stop being usable. + * + * On a grantor ROTATION (compromise recovery) the envelope is now MIGRATED + * rather than invalidated wherever the grantee is still reachable: the browser + * mints a fresh envelope escrowing the new private key and re-points the + * contact through reEnvelopeForRotation(). invalidateForGrantorRotation() then + * runs at completion as a residual SWEEP, catching only the contacts the loop + * could not carry (grantee has no active suite). Revocation of the grantor's + * suite still DELETES the envelopes outright, because it produces no new key to + * migrate to. * * @category Service * @package OCA\Keepiq\Service @@ -27,17 +34,34 @@ namespace OCA\Keepiq\Service; use DateTime; +use InvalidArgumentException; use OCA\Keepiq\Db\EmergencyContact; use OCA\Keepiq\Db\EmergencyContactMapper; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; /** * Key-change invalidation of break-glass recovery envelopes. */ class EmergencyEnvelopeInvalidationService { + /** + * The recovery-envelope format this service knows how to shape-check. + * + * Mirrors ENVELOPE_VERSION / ENVELOPE_ALG in src/crypto/emergencyEnvelope.js. + * The grantor cannot open the envelope (only the grantee can), so the server + * asserts its shape rather than round-tripping it. + */ + private const ENVELOPE_VERSION = 1; + + private const ENVELOPE_ALG = 'RSA-OAEP+AES-256-GCM'; + /** * Constructor for EmergencyEnvelopeInvalidationService. * * @param EmergencyContactMapper $mapper The emergency-contact mapper + * @param EncryptionSuiteMapper $suiteMapper The encryption-suite mapper (grantee active-suite lookup) * @param EmergencyAccessAuditTrail $auditTrail The emergency-access audit trail * * @return void @@ -46,6 +70,7 @@ class EmergencyEnvelopeInvalidationService { */ public function __construct( private EmergencyContactMapper $mapper, + private EncryptionSuiteMapper $suiteMapper, private EmergencyAccessAuditTrail $auditTrail, ) { }//end __construct() @@ -76,6 +101,31 @@ public function invalidateForGrantorRotation(string $grantorSuiteId, string $rea return $count; }//end invalidateForGrantorRotation() + /** + * Count the grantor's usable (non-invalidated) emergency contacts on a suite. + * + * A revocation about to DELETE these envelopes uses this to refuse silently + * destroying a still-working break-glass path: the count (never the + * identities, which stay grantor-private) is surfaced so the administrator + * can decide with the loss in view. + * + * @param string $grantorSuiteId The grantor suite about to be revoked + * + * @return int The number of usable emergency contacts bound to that suite + * + * @spec openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md#requirement-envelope-invalidation-on-key-change + */ + public function countUsableForGrantorSuite(string $grantorSuiteId): int { + $count = 0; + foreach ($this->mapper->findByGrantorSuite(grantorSuiteId: $grantorSuiteId) as $contact) { + if ($contact->getState() !== EmergencyContact::STATE_INVALIDATED) { + $count++; + } + } + + return $count; + }//end countUsableForGrantorSuite() + /** * Clear a grantor's recovery envelopes after their suite is REVOKED — the * envelopes hold a now-void key and are deleted outright. @@ -126,6 +176,148 @@ public function invalidateForGranteeRevocation(string $granteeSuiteId): int { return $count; }//end invalidateForGranteeRevocation() + /** + * Migrate one recovery envelope onto the grantor's new suite during a + * compromise-recovery rotation. + * + * The browser has already minted a fresh envelope escrowing the grantor's + * NEW private key, sealed to the grantee's current certificate. This re-points + * the contact to the new suite and stores that envelope, keeping the contact + * `granted`. Unlike the other migrated stores the grantor cannot decrypt what + * it just wrote (only the grantee can), so the envelope is shape-checked, not + * round-tripped, and the declared grantee suite is asserted to be the + * grantee's CURRENT active suite — an envelope sealed to a stale grantee key + * would be unopenable. + * + * Ownership and old-suite binding are enforced exactly as the other migration + * writes: the caller (MigrationController) has already established that the + * migration belongs to $ownerId, and this insists the contact does too and is + * still on $oldSuiteId before touching it. + * + * @param string $ownerId The migration owner (the contact's grantor) + * @param string $oldSuiteId The migration's old suite (the contact must be on it) + * @param string $newSuiteId The migration's new suite (the contact is re-pointed to it) + * @param string $contactId The emergency-contact ID to re-point + * @param string $recoveryEnvelope The fresh envelope escrowing the new private key + * @param string $sealedSuiteId The grantee suite the client sealed to + * + * @return EmergencyContact The re-pointed contact + * + * @throws NotFoundException When the contact does not exist + * @throws ForbiddenException When the contact is not this migration's to touch + * @throws InvalidArgumentException When the envelope is malformed or misaddressed + * + * @spec openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md#requirement-envelope-invalidation-on-key-change + */ + public function reEnvelopeForRotation( + string $ownerId, + string $oldSuiteId, + string $newSuiteId, + string $contactId, + string $recoveryEnvelope, + string $sealedSuiteId, + ): EmergencyContact { + try { + $contact = $this->mapper->findById(id: $contactId); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'Emergency contact not found'); + } + + if ($contact->getGrantorUserId() !== $ownerId) { + throw new ForbiddenException(message: 'Emergency contact does not belong to you'); + } + + if ($contact->getGrantorSuiteId() !== $oldSuiteId) { + throw new ForbiddenException(message: 'Emergency contact is not bound to this migration\'s old suite'); + } + + $this->assertWellFormedEnvelope(envelope: $recoveryEnvelope); + + // The envelope is only openable by the grantee, so the strongest check the + // grantor's server can make is that it was sealed to the grantee's CURRENT + // suite. A grantee who rotated since designation has a new active suite; + // sealing to the old one would produce an envelope they could never open. + try { + $granteeSuite = $this->suiteMapper->findActiveByOwner(ownerType: 'user', ownerId: $contact->getGranteeUserId()); + } catch (DoesNotExistException) { + throw new InvalidArgumentException('The grantee has no active encryption suite to seal to'); + } + + if ($sealedSuiteId !== $granteeSuite->getId()) { + throw new InvalidArgumentException('The declared grantee suite does not match the grantee\'s active suite'); + } + + $contact->setRecoveryEnvelope($recoveryEnvelope); + $contact->setGrantorSuiteId($newSuiteId); + $contact->setGranteeSuiteId($sealedSuiteId); + + // Re-enveloping carries the escrow across the key rotation; it is NOT a + // lifecycle change, so the state is PRESERVED. Forcing STATE_GRANTED would + // silently veto an in-flight (`requested`) or `approved` break-glass and + // mis-audit that veto as a grant. The one exception is a previously + // invalidated contact — the client should not send one, but if it does, a + // fresh envelope genuinely re-establishes it, so it becomes granted. + if ($contact->getState() === EmergencyContact::STATE_INVALIDATED) { + $contact->setState(EmergencyContact::STATE_GRANTED); + } + + $contact->setInvalidatedReason(null); + $contact->setUpdatedAt(new DateTime()); + $updated = $this->mapper->update($contact); + + // Audit as a (re-)grant only when the escrow is (re-)established to a + // granted contact — never relabel a preserved in-flight or declined + // request as a grant. + if ($updated->getState() === EmergencyContact::STATE_GRANTED) { + $this->auditTrail->recordGranted( + grantorUserId: $updated->getGrantorUserId(), + granteeUserId: $updated->getGranteeUserId(), + id: $updated->getId(), + accessLevel: (string)$updated->getAccessLevel(), + waitPeriodDays: (int)$updated->getWaitPeriodDays(), + ); + } + + return $updated; + }//end reEnvelopeForRotation() + + /** + * Shape-check a recovery envelope without opening it. + * + * Only the grantee can decrypt the envelope, so the server cannot verify its + * plaintext. It can insist the envelope parses, carries the expected version + * and algorithm, and has the three non-empty ciphertext fields the builder + * emits — enough to reject a malformed or truncated submission. + * + * @param string $envelope The recovery-envelope JSON + * + * @return void + * + * @throws InvalidArgumentException When the envelope is not well-formed + * + * @spec openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md#requirement-envelope-invalidation-on-key-change + */ + private function assertWellFormedEnvelope(string $envelope): void { + $decoded = json_decode($envelope, true); + if (is_array($decoded) === false) { + throw new InvalidArgumentException('Recovery envelope is not valid JSON'); + } + + if (($decoded['v'] ?? null) !== self::ENVELOPE_VERSION) { + throw new InvalidArgumentException('Recovery envelope has an unexpected version'); + } + + if (($decoded['alg'] ?? null) !== self::ENVELOPE_ALG) { + throw new InvalidArgumentException('Recovery envelope has an unexpected algorithm'); + } + + foreach (['encKey', 'iv', 'ct'] as $field) { + if (isset($decoded[$field]) === false || is_string($decoded[$field]) === false || $decoded[$field] === '') { + throw new InvalidArgumentException('Recovery envelope is missing its ' . $field . ' field'); + } + } + }//end assertWellFormedEnvelope() + /** * Mark a relationship invalidated: null the envelope, set the reason, and * audit. The grantee can no longer break glass until the grantor re-establishes. diff --git a/lib/Service/EncryptionSuiteService.php b/lib/Service/EncryptionSuiteService.php index fe28c069c..b15bf7156 100644 --- a/lib/Service/EncryptionSuiteService.php +++ b/lib/Service/EncryptionSuiteService.php @@ -150,14 +150,31 @@ public function provisionForApplication(string $applicationId, string $csrPem): * @param string $id The suite ID * @param string $reason The reason for revocation * @param string $revokedBy The user who revoked the suite + * @param bool $markCompromised Treat the suite's secrets as compromised (admin force-revoke) + * @param int $emergencyContactsDestroyed Usable emergency contacts cleared by the revoke (audit only) * * @return EncryptionSuite * * @throws DoesNotExistException * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $markCompromised is the + * administrator's explicit, transient compromise decision (ADR-005), + * threaded onto the dispatched event and the audit metadata; the owner + * path leaves it at its default and stays behaviourally unchanged. + * @SuppressWarnings(PHPMD.LongVariable) $emergencyContactsDestroyed is the + * audit-metadata / response contract name (ADR-005 D5) it threads through; + * the descriptive name is deliberate and matches the surfaced field. + * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-2 + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation */ - public function revokeSuite(string $id, string $reason, string $revokedBy): EncryptionSuite { + public function revokeSuite( + string $id, + string $reason, + string $revokedBy, + bool $markCompromised = false, + int $emergencyContactsDestroyed = 0, + ): EncryptionSuite { $suite = $this->mapper->findById($id); if ($suite->getStatus() === 'compromised') { @@ -175,7 +192,10 @@ public function revokeSuite(string $id, string $reason, string $revokedBy): Encr // Implement-user-sharing §10.3 — dispatch a revocation event so // EncryptionSuiteRevokedListener can cascade share-target - // cleanup and promote temporary delegations to permanent. + // cleanup and promote temporary delegations to permanent. The + // compromise flag drives SuiteCompromiseOnRevokeListener on the + // same event (admin-suite-revocation D2); it stays false on the + // owner path, which never passes $markCompromised. if ($this->eventDispatcher !== null) { $this->eventDispatcher->dispatchTyped( new EncryptionSuiteRevokedEvent( @@ -183,6 +203,7 @@ public function revokeSuite(string $id, string $reason, string $revokedBy): Encr ownerType: $suite->getOwnerType(), ownerId: $suite->getOwnerId(), revokedBy: $revokedBy, + compromised: $markCompromised, ) ); } @@ -193,7 +214,11 @@ public function revokeSuite(string $id, string $reason, string $revokedBy): Encr eventType: AuditEventTypes::SUITE_REVOKED, objectType: 'suite', objectId: $id, - metadata: ['reason' => $reason], + metadata: [ + 'reason' => $reason, + 'markCompromised' => $markCompromised, + 'emergencyContactsDestroyed' => $emergencyContactsDestroyed, + ], ) ); diff --git a/lib/Service/MigrationService.php b/lib/Service/MigrationService.php index 1005a6e2e..f15d8f1b8 100644 --- a/lib/Service/MigrationService.php +++ b/lib/Service/MigrationService.php @@ -25,8 +25,10 @@ use OCA\Keepiq\Db\EncryptionSuiteMapper; use OCA\Keepiq\Db\SuiteMigration; use OCA\Keepiq\Db\SuiteMigrationMapper; +use OCA\Keepiq\Event\SuiteMigrationAbortedEvent; use OCA\Keepiq\Event\SuiteMigrationCompletedEvent; use OCA\Keepiq\Event\SuiteMigrationStartedEvent; +use OCA\Keepiq\Exception\MigrationAbortRefusedException; use OCA\Keepiq\Exception\MigrationIncompleteException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; @@ -222,6 +224,119 @@ public function completeMigration( ); }//end completeMigration() + /** + * Abort a compromise-recovery migration, returning the vault to the old suite. + * + * The abort route the `compromiseRecovery` refusal message already promises. + * It is the non-destructive terminal: completion carries the vault FORWARD to + * the new suite and marks the old one compromised; abort carries it BACK to + * the old suite, which stays `active` and readable. + * + * Abort is permitted ONLY while no record has been committed to the new + * suite. Once a record has moved, the two possible outcomes both lose data — + * discarding the successor strands what has moved, keeping it strands what has + * not — so the migration stays `in_progress` and the caller is pointed at + * resuming. This restriction is also exactly sufficient for the case abort + * exists to remedy: producing a valid re-encrypted record requires the + * plaintext, hence the master password, so a hostile session that never held + * it can never have committed a record and can always be aborted away. + * + * Idempotent by status, like completeMigration: a retried abort on an already + * terminal migration is a no-op, not a second teardown. + * + * @param string $migrationId The migration to abort + * + * @return array The terminal migration plus an `aborted` flag + * + * @throws MigrationAbortRefusedException When a record has already been committed + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/encryption-suites/spec.md#requirement-a-migration-can-be-aborted-before-any-record-moves + */ + public function abortMigration(string $migrationId): array { + $migration = $this->mapper->findById($migrationId); + + if ($migration->getStatus() !== 'in_progress') { + $this->logger->info( + 'Keepiq: abortMigration called on an already-terminated migration; ignoring', + ['migrationId' => $migrationId, 'status' => $migration->getStatus()] + ); + + return [ + 'status' => $migration->getStatus(), + 'aborted' => false, + 'alreadyTerminated' => true, + ]; + } + + $ownerId = $this->resolveOwnerId(suiteId: $migration->getOldSuiteId()); + + // The one gate: nothing may have moved to the new suite yet. + $committed = 0; + if ($ownerId !== null) { + $committed = $this->workService->countCommitted(migration: $migration, ownerId: $ownerId); + } + + if ($committed > 0) { + throw (new MigrationAbortRefusedException( + message: sprintf( + '%d record(s) have already been re-encrypted to the new suite, so this ' + . 'migration can no longer be aborted without losing data. Resume it to finish, ' + . 'or complete it.', + $committed + ) + ))->withCommitted($committed); + } + + // Terminal, but the RESTORATIVE terminal. The old suite is untouched and + // stays active; the successor — created empty moments ago and never + // written to — is discarded. It is DELETED rather than revoked on + // purpose: revoking a user suite runs the lost-identity cascade + // (EncryptionSuiteRevokedListener sweeps the owner's incoming + // ShareTargets and promotes their delegations), which would destroy real + // state over a migration the abort exists to undo. + $migration->setStatus('aborted'); + $migration->setCompletedAt(new DateTime()); + $this->mapper->update($migration); + + try { + $successor = $this->suiteMapper->findById($migration->getNewSuiteId()); + $this->suiteMapper->delete($successor); + } catch (DoesNotExistException) { + // Already gone — nothing to discard. + $this->logger->warning( + 'Keepiq: successor suite already absent during abort', + ['migrationId' => $migrationId, 'newSuiteId' => $migration->getNewSuiteId()] + ); + } + + $this->workService->clearFailureAccounting(migration: $migration); + + // NOT SuiteMigrationCompletedEvent: that event runs the terminal cascade + // (compromise-flagging, link-share revocation, emergency-access + // invalidation) which must never fire for an abort. The aborted event + // carries the single reaction abort needs — releasing the SecretRequests + // that SuiteMigrationStartedListener locked, keeping them on the old + // suite — handled by SuiteMigrationAbortedListener. + $this->eventDispatcher?->dispatchTyped( + new SuiteMigrationAbortedEvent( + oldSuiteId: $migration->getOldSuiteId(), + newSuiteId: $migration->getNewSuiteId(), + migrationId: $migration->getId(), + ) + ); + + $this->logger->info( + "Keepiq: Compromise recovery aborted for migration {$migrationId}; vault returned to the old suite", + ['oldSuiteId' => $migration->getOldSuiteId()] + ); + + return ( + $migration->jsonSerialize() + [ + 'aborted' => true, + ] + ); + }//end abortMigration() + /** * Run everything that must happen — and must be allowed — before a * migration may be marked terminal. diff --git a/lib/Service/MigrationWorkService.php b/lib/Service/MigrationWorkService.php index 0426fbff3..544cba5b2 100644 --- a/lib/Service/MigrationWorkService.php +++ b/lib/Service/MigrationWorkService.php @@ -63,6 +63,13 @@ * one generic entry point would mean passing the store as a parameter on a * per-object write path, which the change's design rejected as an IDOR * footgun (hydra-gate-no-admin-idor). + * @SuppressWarnings(PHPMD.ExcessiveClassLength) The length is the same three + * near-parallel per-store pairs (count / list / commit / drop), each with the + * per-store owner-scoping guard that must not be shared. The class sat just + * under the threshold; countCommitted — the mirror of countOutstanding needed + * by the abort gate, and dependent on the same three mappers only this class + * holds — tipped it over. Splitting the suite-bound stores into their own + * services is a separate refactor, not part of the abort change. * * @spec openspec/specs/encryption-suites/spec.md#requirement-migration-covers-every-suite-bound-store */ @@ -105,8 +112,11 @@ class MigrationWorkService { * @param IDBConnection $db The database connection (per-record transactions) * @param IAppConfig $appConfig The app config (version window override) * @param LoggerInterface $logger The logger interface + * @param EmergencyEnvelopeInvalidationService $emergencyService Counts re-enveloped contacts for the abort gate * * @return void + * + * @spec exclude Constructor wiring only — no domain logic. */ public function __construct( private SecretMapper $secretMapper, @@ -116,6 +126,7 @@ public function __construct( private IDBConnection $db, private IAppConfig $appConfig, private LoggerInterface $logger, + private EmergencyEnvelopeInvalidationService $emergencyService, ) { }//end __construct() @@ -318,6 +329,61 @@ public function countUnrecoverable(SuiteMigration $migration): int { return $this->failureMapper->countByMigration(migrationId: $migration->getId()); }//end countUnrecoverable() + /** + * How many of the owner's records have been committed to the NEW suite. + * + * The successor suite is created empty at the start of a migration, so any + * of the owner's suite-bound rows now pointing at it is a record the + * migration has moved. This is the mirror of countOutstanding(), which + * counts what still sits on the OLD suite. It is what decides whether a + * migration may still be aborted: abort is only safe while nothing has + * moved, because once a record is on the new suite, discarding that suite + * would strand it and keeping it would strand everything still on the old + * one. + * + * @param SuiteMigration $migration The migration + * @param string $ownerId The owner's user id + * + * @return integer + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/encryption-suites/spec.md#requirement-a-migration-can-be-aborted-before-any-record-moves + */ + public function countCommitted(SuiteMigration $migration, string $ownerId): int { + $newSuiteId = $migration->getNewSuiteId(); + + $secrets = $this->secretMapper->countBySuiteForOwner( + encryptionSuiteId: $newSuiteId, + ownerType: 'user', + ownerId: $ownerId + ); + $versions = $this->versionMapper->countBySuiteForOwner( + encryptionSuiteId: $newSuiteId, + ownerType: 'user', + ownerId: $ownerId + ); + $grants = $this->grantMapper->countBySuiteForRecipient( + encryptionSuiteId: $newSuiteId, + recipientType: 'user', + recipientId: $ownerId + ); + + // Emergency contacts are re-enveloped onto the new suite during the run + // (migrate-emergency-access-on-rotation), and that re-envelope OVERWRITES + // the old envelope — it cannot be undone. So a contact now bound to the new + // suite is a moved record exactly like a re-encrypted secret: aborting past + // it would discard the new suite and strand the contact on a deleted suite + // with an envelope escrowing a discarded key, while the residual sweep + // (which queries the OLD suite) never sees it. Counting it here makes abort + // refuse once any contact has been carried, keeping the gate's invariant + // whole (a grantor's contacts sit on the grantor's own new suite, so this + // count is already owner-scoped). + $contacts = $this->emergencyService->countUsableForGrantorSuite( + grantorSuiteId: $newSuiteId + ); + + return ($secrets + $versions + $grants + $contacts); + }//end countCommitted() + /** * Drop this migration's failure accounting. * diff --git a/lib/Service/SiemService.php b/lib/Service/SiemService.php index dcf678396..07b3f210c 100644 --- a/lib/Service/SiemService.php +++ b/lib/Service/SiemService.php @@ -36,6 +36,7 @@ use OCA\Keepiq\Db\SiemSinkMapper; use OCA\Keepiq\Event\Audit\AuditEvent; use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCA\Keepiq\Service\Connection\ConnectionReporter; use OCP\IGroupManager; use Psr\Log\LoggerInterface; use Ramsey\Uuid\Uuid; @@ -79,8 +80,11 @@ class SiemService { * @param IGroupManager $groupManager The group manager (admin notifications) * @param NotificationService|null $notificationService The notification dispatcher * @param LoggerInterface $logger The logger + * @param ConnectionReporter|null $connectionReporter Tells integriq what a drain met, or nothing when absent * * @return void + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met */ public function __construct( private SiemSinkMapper $sinkMapper, @@ -90,6 +94,7 @@ public function __construct( private IGroupManager $groupManager, private ?NotificationService $notificationService, private LoggerInterface $logger, + private ?ConnectionReporter $connectionReporter = null, ) { }//end __construct() @@ -180,13 +185,23 @@ public function enqueue(array $payload): int { /** * Drain due rows for every enabled sink in bounded batches (§4.1). * + * After the drain, the sinks it tried to deliver to are handed to the + * connection reporter, which reports the SIEM export row at most once an + * hour while the outcome stays the same (adopt-connection-registry). The + * drain runs from cron, never from a page request. + * * @return int Rows delivered + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met */ public function deliverDue(): int { - $delivered = 0; - foreach ($this->sinkMapper->findEnabled() as $sink) { + $delivered = 0; + $enabledSinks = $this->sinkMapper->findEnabled(); + $attempted = []; + foreach ($enabledSinks as $sink) { $hadDeadBefore = $this->queueMapper->countDead($sink->getId()) > 0; foreach ($this->queueMapper->findDue(sinkId: $sink->getId(), now: new DateTime()) as $item) { + $attempted[$sink->getId()] = $sink; if ($this->deliverOne(sink: $sink, item: $item) === true) { ++$delivered; } @@ -198,7 +213,13 @@ public function deliverDue(): int { if ($hadDeadBefore === false && $this->queueMapper->countDead($sink->getId()) > 0) { $this->notifyDeadLetter(sink: $sink); } - } + }//end foreach + + $this->connectionReporter?->reportSiemDrain( + enabledSinks: count($enabledSinks), + attemptedSinks: array_values($attempted), + sinkCount: fn (): int => count($this->sinkMapper->findAll()) + ); return $delivered; }//end deliverDue() diff --git a/lib/Service/SiemSinkService.php b/lib/Service/SiemSinkService.php index d64b68dc9..6658ff307 100644 --- a/lib/Service/SiemSinkService.php +++ b/lib/Service/SiemSinkService.php @@ -33,6 +33,7 @@ use OCA\Keepiq\Db\SiemQueueItemMapper; use OCA\Keepiq\Db\SiemSink; use OCA\Keepiq\Db\SiemSinkMapper; +use OCA\Keepiq\Service\Connection\ConnectionReporter; use OCP\AppFramework\Db\DoesNotExistException; use OCP\Security\ICrypto; use Ramsey\Uuid\Uuid; @@ -58,6 +59,7 @@ class SiemSinkService { * @param ICrypto $crypto NC crypto (HMAC secret at rest) * @param SiemTransport $transport The sink transport (test-fire) * @param SiemAuditTrail|null $auditTrail The sink audit trail + * @param ConnectionReporter|null $connectionReporter Asks integriq to look again after a sink change, or nothing when absent * * @return void * @@ -69,6 +71,7 @@ public function __construct( private ICrypto $crypto, private SiemTransport $transport, ?SiemAuditTrail $auditTrail = null, + private ?ConnectionReporter $connectionReporter = null, ) { $this->auditTrail = ($auditTrail ?? new SiemAuditTrail()); }//end __construct() @@ -114,6 +117,7 @@ public function createSink(string $adminUid, array $params): SiemSink { $sink = $this->sinkMapper->insert($sink); $this->auditTrail->recordSinkCreated(actorId: $adminUid, sinkId: $sink->getId(), type: $type); + $this->reportSinksChanged(); return $sink; }//end createSink() @@ -158,6 +162,7 @@ public function updateSink(string $adminUid, string $sinkId, array $params): Sie $sink = $this->sinkMapper->update($sink); $this->auditTrail->recordSinkUpdated(actorId: $adminUid, sinkId: $sinkId); + $this->reportSinksChanged(); return $sink; }//end updateSink() @@ -180,6 +185,7 @@ public function deleteSink(string $adminUid, string $sinkId): void { $this->sinkMapper->delete($sink); $this->auditTrail->recordSinkDeleted(actorId: $adminUid, sinkId: $sinkId); + $this->reportSinksChanged(); }//end deleteSink() /** @@ -262,4 +268,21 @@ private function applySecretAndFilter(SiemSink $sink, array $params): void { $sink->setCategoryFilter($encoded); } }//end applySecretAndFilter() + + /** + * Ask integriq to resolve SIEM export again after a sink change. + * + * The reporter counts the enabled sinks only when integriq is installed, + * and never throws (adopt-connection-registry). + * + * @return void + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met + */ + private function reportSinksChanged(): void { + $this->connectionReporter?->siemSinksChanged( + enabledSinkCount: fn (): int => count($this->sinkMapper->findEnabled()), + sinkCount: fn (): int => count($this->sinkMapper->findAll()) + ); + }//end reportSinksChanged() }//end class diff --git a/lib/Service/VaultKeyProofService.php b/lib/Service/VaultKeyProofService.php new file mode 100644 index 000000000..8846913a4 --- /dev/null +++ b/lib/Service/VaultKeyProofService.php @@ -0,0 +1,268 @@ + + * @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\Service; + +use OCA\Keepiq\Exception\KeyProofRequiredException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use OCP\Security\ISecureRandom; +use RuntimeException; + +/** + * Stateless issuance and verification of vault-key proofs. + */ +class VaultKeyProofService { + /** + * How long a challenge is valid, in seconds. + */ + private const TTL = 300; + + /** + * Stable public purpose identifiers. Both the guarded method's attribute and + * the client's challenge request name one of these, and the challenge is + * bound to it, so a proof for one operation cannot be presented to another. + */ + public const PURPOSE_COMPROMISE_RECOVERY = 'compromise-recovery'; + public const PURPOSE_UPDATE_PRIVATE_KEY = 'update-private-key'; + public const PURPOSE_COMPLETE_MIGRATION = 'complete-migration'; + public const PURPOSE_EMERGENCY_DESTROY = 'emergency-access-destroy'; + public const PURPOSE_REVOKE_SUITE = 'revoke-suite'; + + /** + * The purposes a challenge may be issued for. + */ + public const ALLOWED_PURPOSES = [ + self::PURPOSE_COMPROMISE_RECOVERY, + self::PURPOSE_UPDATE_PRIVATE_KEY, + self::PURPOSE_COMPLETE_MIGRATION, + self::PURPOSE_EMERGENCY_DESTROY, + self::PURPOSE_REVOKE_SUITE, + ]; + + /** + * Constructor. + * + * @param IConfig $config The system config, for the instance secret + * @param ISecureRandom $secureRandom The challenge randomness source + * @param ITimeFactory $timeFactory The clock, injected for testable expiry + * + * @return void + */ + public function __construct( + private IConfig $config, + private ISecureRandom $secureRandom, + private ITimeFactory $timeFactory, + ) { + }//end __construct() + + /** + * Issue a challenge for a caller and a purpose. + * + * @param string $userId The caller's user id + * @param string $purpose The operation the challenge authorises + * + * @return array{nonce:string,expiresAt:int} + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + */ + public function issueChallenge(string $userId, string $purpose): array { + $expiresAt = ($this->timeFactory->getTime() + self::TTL); + + $payload = $this->b64url(raw: (string)json_encode([ + 'r' => base64_encode($this->secureRandom->generate(18)), + 'u' => $userId, + 'p' => $purpose, + 'e' => $expiresAt, + ])); + + $nonce = $payload . '.' . $this->mac(payload: $payload); + + return ['nonce' => $nonce, 'expiresAt' => $expiresAt]; + }//end issueChallenge() + + /** + * Verify a proof, or throw. + * + * Every failure path throws the same KeyProofRequiredException with no + * indication of which check failed, so a caller learns only pass/fail. + * + * @param string $nonce The challenge the client echoed back + * @param string $signatureB64 The base64 signature over the bound payload + * @param string $certificatePem The subject suite's certificate (its public key) + * @param string $userId The caller, which the challenge must name + * @param string $purpose The operation, which the challenge must name + * @param string[] $boundValues The request parameter values the proof commits to + * + * @return void + * + * @throws KeyProofRequiredException When the proof is absent, stale, mis-bound or invalid + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-irreversible-operations-require-a-verified-key-proof + */ + public function verify( + string $nonce, + string $signatureB64, + string $certificatePem, + string $userId, + string $purpose, + array $boundValues, + ): void { + $claims = $this->authenticateNonce(nonce: $nonce); + + if (($claims['u'] ?? null) !== $userId || ($claims['p'] ?? null) !== $purpose) { + throw new KeyProofRequiredException(message: 'Challenge does not match this operation'); + } + + if ((int)($claims['e'] ?? 0) < $this->timeFactory->getTime()) { + throw new KeyProofRequiredException(message: 'Challenge has expired'); + } + + $publicKey = openssl_pkey_get_public($certificatePem); + if ($publicKey === false) { + throw new KeyProofRequiredException(message: 'Subject public key unreadable'); + } + + $signature = base64_decode($signatureB64, true); + if ($signature === false) { + throw new KeyProofRequiredException(message: 'Malformed proof'); + } + + $verified = openssl_verify( + $this->signedMessage(nonce: $nonce, boundValues: $boundValues), + $signature, + $publicKey, + OPENSSL_ALGO_SHA256 + ); + + if ($verified !== 1) { + throw new KeyProofRequiredException(message: 'Proof does not verify'); + } + }//end verify() + + /** + * The exact string a valid proof signs: the challenge, then the SHA-256 of + * each bound value in declared order, one per line. The client builds the + * identical string, so only named scalar parameters cross the language + * boundary — no JSON-canonicalisation agreement is needed. + * + * @param string $nonce The challenge + * @param string[] $boundValues The bound request-parameter values, in order + * + * @return string + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-a-proof-is-bound-to-the-operation-it-authorises + */ + public function signedMessage(string $nonce, array $boundValues): string { + $lines = [$nonce]; + foreach ($boundValues as $value) { + $lines[] = hash('sha256', (string)$value); + } + + return implode("\n", $lines); + }//end signedMessage() + + /** + * Recover and authenticate a challenge's claims, or throw. + * + * @param string $nonce The challenge string + * + * @return array + * + * @throws KeyProofRequiredException When the challenge is absent or forged + */ + private function authenticateNonce(string $nonce): array { + if ($nonce === '') { + throw new KeyProofRequiredException(message: 'No challenge presented'); + } + + $parts = explode('.', $nonce); + if (count($parts) !== 2) { + throw new KeyProofRequiredException(message: 'Malformed challenge'); + } + + [$payload, $mac] = $parts; + if (hash_equals($this->mac(payload: $payload), $mac) === false) { + throw new KeyProofRequiredException(message: 'Challenge failed authentication'); + } + + $json = base64_decode(strtr($payload, '-_', '+/'), true); + if ($json === false) { + throw new KeyProofRequiredException(message: 'Unreadable challenge'); + } + + $claims = json_decode($json, true); + if (is_array($claims) === false) { + throw new KeyProofRequiredException(message: 'Unreadable challenge'); + } + + return $claims; + }//end authenticateNonce() + + /** + * The HMAC of a payload under the instance secret, base64url-encoded. + * + * @param string $payload The base64url payload + * + * @return string + * + * @throws RuntimeException When the instance secret is unset — a key-material + * control must fail loudly, not silently degrade to an empty HMAC key. + */ + private function mac(string $payload): string { + $secret = $this->config->getSystemValueString('secret', ''); + if ($secret === '') { + throw new RuntimeException( + 'Cannot compute a vault-key-proof MAC: the Nextcloud instance secret ' + . 'is unset. A key-material control must not degrade to an empty key.' + ); + } + + return $this->b64url(raw: hash_hmac('sha256', $payload, $secret, true)); + }//end mac() + + /** + * URL-safe, unpadded base64. + * + * @param string $raw The raw bytes + * + * @return string + */ + private function b64url(string $raw): string { + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + }//end b64url() +}//end class diff --git a/lib/Settings/connections.json b/lib/Settings/connections.json new file mode 100644 index 000000000..e27c2577e --- /dev/null +++ b/lib/Settings/connections.json @@ -0,0 +1,26 @@ +{ + "app": "keepiq", + "connections": [ + { + "key": "hibp", + "title": "Breach check", + "description": "Checks password hash prefixes against Have I Been Pwned, once an admin switches it on and a user opts in.", + "order": 10, + "settingsUrl": "/settings/admin/keepiq#section-breach-check", + "switch": { + "configKey": "breach_check_enabled" + }, + "disabledMessage": "Breach checking is switched off. Switch it on under Breach checking in the Keepiq admin settings.", + "unconfiguredMessage": "Not checked yet. Keepiq reports here after the next password check reaches Have I Been Pwned." + }, + { + "key": "siem", + "title": "SIEM audit export", + "description": "Forwards whitelisted audit events to syslog or webhook sinks. This row covers every sink.", + "order": 20, + "settingsUrl": "/settings/admin/keepiq#section-siem", + "reportedOnly": true, + "unconfiguredMessage": "Not checked yet. Keepiq reports here after it delivers audit events to a sink." + } + ] +} diff --git a/openspec/architecture/adr-005-admin-suite-force-revocation-and-compromise-signalling.md b/openspec/architecture/adr-005-admin-suite-force-revocation-and-compromise-signalling.md new file mode 100644 index 000000000..b6148e354 --- /dev/null +++ b/openspec/architecture/adr-005-admin-suite-force-revocation-and-compromise-signalling.md @@ -0,0 +1,158 @@ +# ADR-005: Administrator Suite Force-Revocation and Compromise Signalling + +**Status**: accepted + +**Date**: 2026-09-14 + +## Context + +Owner-initiated suite revocation now requires a verified vault-key proof (change +`harden-vault-key-material-guards`, #673): the caller must sign a challenge with +the suite's own private key. An administrator cannot produce that proof — the +vault is zero-knowledge and the server never holds a usable private key — yet +administrators must still be able to revoke a suite they do not own, for three +real situations: + +1. **Forgotten master password.** The user is locked out of their own vault. + Because #673 blocks a proofless rotation, administrator revocation is the + *only* way back to a working vault: the dead suite is revoked so the user can + re-onboard with a fresh one. This is the lost-password route of #395. +2. **De-authorisation.** The user leaves and their access must be pulled. They + may still *know* the secrets they could read. +3. **Compromise.** The private key or master password is in an attacker's hands + (e.g. a stolen, MDM-wiped laptop; a changed master password making the suite + irrecoverable). + +Application-owned suites additionally have no human owner who can produce a +proof at all, so administrator revocation is their only revocation path. + +Revocation is destructive: `EncryptionSuiteRevokedListener` deletes the owner's +inbound `ShareTarget`s, promotes their temporary delegations, and the emergency +listener clears their break-glass recovery envelopes; all secret reads are then +refused. + +Whether the revoked suite's secrets should be treated as **compromised** — and +so flagged for rotation — is a *human, situational* judgment, not a property of +the act of revoking: + +- A forgotten password whose audit trail shows no access since the user's last + legitimate use is no compromise at all. +- A stolen key is unambiguously a compromise. +- An amicable departure where secrets are long, generated, non-memorable + passwords sits in between, and a trusting administrator may reasonably decide + *not* to rotate everything. + +The infrastructure to act on a compromise already exists, but is wired to the +compromise-*recovery rotation* path, not to revocation: + +- `Secret.possibly_compromised_at` (field, `jsonSerialize`, compliance count). +- `RotationFlagService::flagCompromisedSecrets(ownerId)` — idempotent, raises + `suite_compromise` rotation flags for every flagged secret of an owner. +- `NotificationService::notify(subject: 'secret_compromised', …)`. +- `SuiteCompromiseListener` — on `SuiteMigrationCompletedEvent`, walks secrets on + the **new** suite and notifies owners; keyed on migration, absent for revoke. +- `SecretMapper::findByEncryptionSuiteId($suiteId)` — every secret sealed under a + suite's key (the owner's own plus received shared copies) = the exact blast + radius of that key. + +`revoked_reason` is an existing free-form `STRING(255)` column, read only by the +GDPR export and `jsonSerialize`, consumed by no behavioural code. + +## Decision + +Add `POST /api/v1/suites/{id}/force-revoke`, an administrator endpoint that +revokes **any** suite by id (user- or application-owned), guarded by: + +- `#[AuthorizedAdminSetting(AdminSettings::class)]` — administrator only, + mirroring the existing admin-only `reinstate()`; and +- `#[PasswordConfirmationRequired]` — Nextcloud sudo mode. The administrator + re-confirms their **own** password; there is no vault key to prove. This is + the app's first use of `PasswordConfirmationRequired`. + +It reuses `EncryptionSuiteService::revokeSuite()`, which is owner-agnostic and +records `revokedBy` (the administrator). + +**Reason.** The administrator supplies a **required, free-form** reason, stored +in the existing `revoked_reason` field (GDPR: the specific "why" must be +recordable). No new column, no migration. + +**Compromise decision.** Whether to treat the suite as compromised is an +**explicit, transient request parameter** `markCompromised` (default `false`), +**not persisted** as a suite column: + +- When `true`, the revoke path flags every secret sealed under the suite + (`findByEncryptionSuiteId`) as `possibly_compromised_at`, raises + `suite_compromise` rotation flags via `RotationPolicyService`, and notifies the + affected owners — reusing the existing cascade primitives, adapted to the + revoke path (no migration; scope is the revoked suite itself). +- When `false`, no cascade runs, and the UI shows a warning that the revoked + user still knows these secrets and rotation may be warranted. + +**Emergency access** is cleared unconditionally — revocation is authoritative — +but the count of destroyed *usable* emergency contacts +(`countUsableForGrantorSuite`) is recorded in the audit metadata and surfaced to +the administrator as an informational warning, **not a gate**. This matters most +for the forgotten-password case, where emergency access may be the user's +genuine recovery route and revoking deletes it. + +**Audit.** The `SUITE_REVOKED` audit event's metadata carries +`{ reason, markCompromised, emergencyContactsDestroyed }`. + +**After revocation**, a user left with no active suite re-onboards with a fresh +suite through the existing onboarding flow. + +## Consequences + +**Positive:** + +- Reuses the existing revoke, compromise-flag, rotation-flag and notification + infrastructure; the only net-new backend logic is the endpoint, the guard + wiring, and adapting the compromise cascade to the revoke (no-migration) path. +- No schema change and no `` bump — `revoked_reason` is reused and the + compromise trigger is never persisted. +- The compromise decision is a deliberate, informed, audited human act rather + than an automatic or text-derived one. +- One endpoint covers all three administrator scenarios; application-suite + revocation gains a first-class, properly guarded path. + +**Negative / trade-offs:** + +- `markCompromised` is not queryable off the suite table — only via the audit + trail or the flagged secrets. Acceptable: no UI needs it, and the durable + compromise evidence lives on the secrets it flags. +- The compromise cascade adds a branch/listener that must be tested for the + revoke path specifically (it cannot ride the migration-complete tests). +- Sudo mode adds a re-authentication step administrators must complete; it is + new to this app and needs a client-side confirmation flow. + +## Alternatives Considered + +- **Persist a `revoked_type` enum column.** Rejected: a new column plus a + migration to encode what the free-form reason and the durable per-secret flags + already convey — two columns for one concept. +- **Always cascade on any revocation.** Rejected: a forgotten password with a + clean audit trail, or an amicable departure with strong generated passwords, is + not a compromise; forcing rotation there is noise. The judgment is the + administrator's to make. +- **Derive compromise from the free-form reason text.** Rejected: parsing human + text ("compromised" vs "key leaked" vs "laptop stolen") to drive a destructive, + effectively irreversible cascade is fragile. +- **Gate revoke on emergency access** (as the owner path gates on + `acceptEmergencyLoss`). Rejected: administrator revocation is authoritative and + is frequently *itself* the offboarding or compromise response; surface the + count as a warning and in the audit instead of blocking. +- **Require a vault-key proof like the owner path.** Impossible: the + administrator holds no vault key (zero-knowledge). Sudo mode is the correct + administrator re-authentication. + +## Related + +- ADR-002 (polymorphic suite ownership — the `user`/`application` owner types a + single force-revoke endpoint serves) +- ADR-003 (always-E2E encryption — why an administrator cannot hold a vault key) +- Change `harden-vault-key-material-guards` (#673 — the owner-revoke vault-key + proof this endpoint is the administrator counterpart to) +- Change `migrate-emergency-access-on-rotation` (#674 — `countUsableForGrantorSuite` + and the emergency-clear-on-revoke behaviour) +- Encryption-suites spec open question "Forced intermediate revocation and secret + compromise" — the analogous question one layer down, at the CA intermediate diff --git a/openspec/changes/admin-suite-revocation/.openspec.yaml b/openspec/changes/admin-suite-revocation/.openspec.yaml new file mode 100644 index 000000000..a40cb63c1 --- /dev/null +++ b/openspec/changes/admin-suite-revocation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/admin-suite-revocation/design.md b/openspec/changes/admin-suite-revocation/design.md new file mode 100644 index 000000000..bf6fc9e68 --- /dev/null +++ b/openspec/changes/admin-suite-revocation/design.md @@ -0,0 +1,83 @@ +# Design — admin-suite-revocation + +## Context + +Owner-initiated revocation is now `revoke()` on `EncryptionSuiteController` (lib/Controller/EncryptionSuiteController.php:315), guarded by `#[NoAdminRequired]` + `#[VaultKeyProofRequired(...PURPOSE_REVOKE_SUITE)]` and self-scoped via `validateOwnership()`. It calls the owner-agnostic `EncryptionSuiteService::revokeSuite($id, $reason, $revokedBy)` (lib/Service/EncryptionSuiteService.php:160), which sets `status='revoked'`, stamps `revoked_at`/`revoked_reason`/`revoked_by`, dispatches `EncryptionSuiteRevokedEvent` (cascade: delete inbound `ShareTarget`s, promote temporary delegations, clear emergency envelopes) and a `SUITE_REVOKED` audit event whose metadata today carries only `['reason']`. + +The vault-key proof is unproducible by an administrator: the server never holds a usable private key (ADR-003, zero-knowledge). So an administrator has no revocation path at all today, even though ADR-005 identifies three real situations that demand one (forgotten password, de-authorisation, compromise) plus application-owned suites, which have no human owner to sign a proof. + +The compromise-signalling infrastructure already exists but is wired to the migration/recovery path, not to revocation: + +- `Secret.possibly_compromised_at` (field + `jsonSerialize` + compliance count). +- `RotationPolicyService::flagCompromisedSecrets($ownerId)` → `RotationFlagService::flagCompromisedSecrets` — idempotent, raises `suite_compromise` flags. +- `NotificationService` subject `secret_compromised` (routed via `notify_security`). +- `SecretMapper::findByEncryptionSuiteId($suiteId)` — every secret sealed under a suite's key (owner's own plus received shared copies) = the exact blast radius. +- `SuiteCompromiseListener` — walks the **new** suite's secrets on `SuiteMigrationCompletedEvent` and notifies; keyed on migration, absent for revoke. +- `EmergencyEnvelopeInvalidationService::countUsableForGrantorSuite($grantorSuiteId)` (lib/Service/EmergencyEnvelopeInvalidationService.php:118). + +`revoked_reason` is an existing free-form `STRING(255)` column, read only by GDPR export and `jsonSerialize`, consumed by no behavioural code. + +## Goals / Non-Goals + +**Goals:** +- One administrator endpoint that revokes any suite by id (user- or application-owned), guarded by admin + sudo, with a required free-form reason. +- An explicit, transient compromise decision (`markCompromised`) that, when set, drives the existing flag/rotation/notification cascade over the revoked suite's blast radius — no new persistence. +- Emergency access cleared unconditionally, with the destroyed-usable count surfaced (audit + warning), never gating the revocation. +- No schema change, no migration, no `` bump. + +**Non-Goals:** +- Changing the owner path's behaviour — `revoke()` calls `revokeSuite()` without `markCompromised`, so it stays behaviourally identical (`revokeSuite()` gains an optional param defaulting off, and the new listener no-ops when the flag is false). +- Persisting the compromise decision as a suite column, or deriving it from the reason text. +- Reproducing the owner path's `acceptEmergencyLoss` gate — administrator revocation is authoritative and is frequently *itself* the offboarding/compromise response. +- Re-onboarding logic — a user left with no active suite re-onboards through the existing onboarding flow. +- **Temporary / vacation suspension** — a reversible "lock the owner out but keep emergency break-glass working during their absence" mode is explicitly deferred to a future change. It is not a flag on force-revoke: `fetchEnvelope()` does not gate on grantor suite status, but the emergency-envelope clear is a separate listener on `EncryptionSuiteRevokedEvent` and the grantee's read of the grantor's secret ciphertext throws `SuiteBlockedException` on a `revoked`/`compromised` suite (`SecretService`), so a usable suspension needs a new owner-locked-but-break-glass-permitted suite state and read-path gating — genuinely its own mechanism. Omitting it does not weaken security: all three cases this change serves (forgotten-password, de-authorisation, compromise) are permanent revocations for which clearing emergency access is correct. + +## Decisions + +### D1: New `forceRevoke()` controller method, admin + sudo guarded + +Add `EncryptionSuiteController::forceRevoke(string $id, string $reason, bool $markCompromised = false)` with `#[AuthorizedAdminSetting(AdminSettings::class)]` (mirroring the existing `reinstate()` at line 383) and `#[PasswordConfirmationRequired]`. The route `POST /api/v1/suites/{id}/force-revoke` is registered in `appinfo/routes.php` alongside the other `encryptionSuite#…` suite routes and before the SPA catch-all wildcard. The acting administrator is resolved via `OCP\IUserSession` and recorded as `revokedBy`. A missing/empty `reason` is rejected (`STATUS_BAD_REQUEST`). This is the app's first use of `PasswordConfirmationRequired`; the middleware enforces sudo before the controller body runs, so no in-body password handling is needed. + +`forceRevoke()` deliberately does **not** call `validateOwnership()` — the whole point is cross-owner revocation, and the `AuthorizedAdminSetting` guard is the authorization (the existing `reinstate()` establishes this admin-only-by-guard pattern). This must be visible to the `no-admin-idor` gate as an admin-guarded method, not an unguarded `NoAdminRequired` one. + +### D2: Compromise cascade as a revoke-event listener + +`EncryptionSuiteRevokedEvent` gains a `compromised` flag, and `revokeSuite()` accepts a `bool $markCompromised = false` that it sets on the event it already dispatches. A new listener — `SuiteCompromiseOnRevokeListener`, a sibling to the existing `EncryptionSuiteRevokedListener` on the same event — reacts only when the flag is true: it walks `SecretMapper::findByEncryptionSuiteId($id)` (the exact blast radius), stamps `possibly_compromised_at` on each secret, raises `suite_compromise` flags via `RotationPolicyService::flagCompromisedSecrets` (idempotent), and notifies the affected owners with the `secret_compromised` subject — the same three primitives `SuiteCompromiseListener` uses, but driven by the revoke event instead of `SuiteMigrationCompletedEvent` (there is no migration here, and the scope is the revoked suite itself). This is idiomatic to the existing revoke-cascade listeners and keeps the compromise logic in its own separately-testable class, independent of the migration-complete tests (ADR-005 flags this need). + +The owner path is behaviourally unchanged: `revoke()` calls `revokeSuite()` without `markCompromised` (default `false`), so the event's `compromised` flag is false and the new listener is a no-op — the owner path never triggers the cascade. + +When `markCompromised === false`, the listener does nothing; the response carries a warning (surfaced in the UI) that the revoked user may still know these secrets and rotation may be warranted. + +### D3: `reason` in the existing column; `markCompromised` never persisted + +`reason` reuses `revoked_reason` — GDPR requires the specific "why" be recordable, and the free-form column already exists. `markCompromised` is a transient request parameter that drives the cascade branch and is written only to the audit metadata; it is never a suite column. This is the ADR-005 decision to reject a `revoked_type` enum column: the durable compromise evidence lives on the per-secret `possibly_compromised_at` flags the cascade raises, and the decision itself is in the audit trail. No new column, no migration. + +### D4: Emergency access cleared unconditionally; count audited and warned, never gated + +Revocation is authoritative, so emergency envelopes are cleared as part of the existing `EncryptionSuiteRevokedEvent` cascade — no `acceptEmergencyLoss` gate. Before (or as part of) the revoke the path reads `countUsableForGrantorSuite($id)` and threads that integer into the `SUITE_REVOKED` audit metadata as `emergencyContactsDestroyed`, and returns it so the administrator sees an informational warning. This matters most for the forgotten-password case, where emergency access may have been the user's genuine recovery route and revoking deletes it — but ADR-005 makes it a warning, not a block, because administrator revocation is frequently the offboarding/compromise response itself. Only the count crosses the wire; contact identities stay grantor-private. + +### D5: Audit metadata widened to three keys + +`AuditEventTypes` currently whitelists `SUITE_REVOKED => ['reason']` (lib/Event/Audit/AuditEventTypes.php:217). Widen it to `['reason', 'markCompromised', 'emergencyContactsDestroyed']` and have the revoke path emit all three. Because the owner `revoke()` path emits only `reason` today, the two extra keys are simply absent there (the whitelist permits, it does not require), so the owner path is unaffected. + +### D6: New admin "Encryption suites" settings section + +There is no admin suite-management UI today (the sibling `reinstate()` is API-only). This change adds a new section to the admin settings area (alongside `AdminApplicationsView` / `AdminAuditSection`, under `AdminRoot.vue`) where an administrator looks up a suite (by owner/id) and force-revokes it. The action presents a required reason field, a `markCompromised` toggle (default off), and — because the endpoint carries `PasswordConfirmationRequired` — the Nextcloud sudo (`OC.PasswordConfirmation` / password-confirmation) flow before the request is sent. On success the returned `emergencyContactsDestroyed` count is rendered as an informational warning, and when `markCompromised` was left off, the "user may still know these secrets" copy is shown. Built with `@conduction/nextcloud-vue` components and the NL Design System double-fallback CSS pattern, consistent with the rest of the settings surface. This section is also the home for `reinstate()`, wired in below. + +The same surface also gains a **reinstate** action for revoked suites, wired to the existing admin-only `reinstate()` endpoint (`POST /api/v1/suites/{id}/reinstate`, EncryptionSuiteController:383) which has no frontend today. This is frontend-only — the endpoint and `reinstateSuite()` service are unchanged — and it carries no `PasswordConfirmationRequired`, so no sudo flow is needed; the `AuthorizedAdminSetting` guard is the authorization. Surfacing revoke and reinstate together keeps the admin suite lifecycle in one place; the action is shown only for suites in `revoked` status (mirroring `reinstateSuite()`'s own precondition). + +## Risks / Trade-offs + +- **`markCompromised` is not queryable off the suite table** — only via the audit trail or the flagged secrets. Accepted (ADR-005): no UI needs it, and the durable evidence lives on the secrets it flags. +- **The compromise cascade adds a revoke-path branch that must be tested for the revoke path specifically** — it cannot ride `SuiteCompromiseListener`'s migration-complete tests. D2 keeps it an explicit, separately-testable branch for exactly this reason. +- **Sudo mode is new to this app** and adds a client-side re-authentication step administrators must complete; D6 owns the confirmation flow. +- **Clearing emergency access on a forgotten-password revoke may destroy the user's genuine recovery route.** Mitigated by surfacing the destroyed-usable count as a warning before the administrator commits, and recording it in the audit trail — but not gated, per ADR-005. +- **`forceRevoke()` omits `validateOwnership()` by design.** This is correct (admin cross-owner action) but must be legible to reviewers and the `no-admin-idor` gate as guarded by `AuthorizedAdminSetting`, mirroring `reinstate()`. + +## Migration Plan + +No data migration and no `` bump. `revoked_reason` is reused; `markCompromised` is transient. The `AuditEventTypes` whitelist widening is code-only and backward-compatible (extra keys are optional). Existing owner-path revocations are unchanged. + +## Open Questions + +- **Where the emergency-count read sits relative to the cascade delete.** `countUsableForGrantorSuite` MUST be read before the `EncryptionSuiteRevokedEvent` cascade clears the envelopes (the owner path reads it before `revokeSuite()` for the same reason); apply must order the read before the dispatch so the count is non-zero when contacts existed. diff --git a/openspec/changes/admin-suite-revocation/plan.json b/openspec/changes/admin-suite-revocation/plan.json new file mode 100644 index 000000000..298d8c3ce --- /dev/null +++ b/openspec/changes/admin-suite-revocation/plan.json @@ -0,0 +1,182 @@ +{ + "change": "admin-suite-revocation", + "project": "keepiq", + "repo": "ConductionNL/keepiq", + "base_branch": "development", + "feature_branch": "feature/702/admin-suite-revocation", + "created": "2026-09-14", + "tracking_issue": 702, + "tasks": [ + { + "id": 1, + "num": "1.1", + "title": "Add EncryptionSuiteController::forceRevoke() guarded by AuthorizedAdminSetting + PasswordConfirmationRequired; resolve the acting admin as revokedBy; reuse revokeSuite(); do not call validateOwnership()", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["lib/Controller/EncryptionSuiteController.php"] + }, + { + "id": 2, + "num": "1.2", + "title": "Register POST /api/v1/suites/{id}/force-revoke (encryptionSuite#forceRevoke) in appinfo/routes.php before the SPA catch-all", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["appinfo/routes.php"] + }, + { + "id": 3, + "num": "1.3", + "title": "Reject empty/missing reason with STATUS_BAD_REQUEST; store it in the existing revoked_reason field; reuse the owner revoke() exception mapping", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["lib/Controller/EncryptionSuiteController.php"] + }, + { + "id": 4, + "num": "2.1", + "title": "Add a compromised flag to EncryptionSuiteRevokedEvent + markCompromised param to revokeSuite(); add SuiteCompromiseOnRevokeListener that (only when true) flags secrets possibly_compromised_at, raises suite_compromise flags, and notifies owners", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["lib/Event/EncryptionSuiteRevokedEvent.php", "lib/Service/EncryptionSuiteService.php", "lib/Listener/SuiteCompromiseOnRevokeListener.php", "lib/AppInfo/Application.php"] + }, + { + "id": 5, + "num": "2.2", + "title": "When markCompromised is false, run no cascade and return the rotation-may-be-warranted warning for the UI", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["lib/Controller/EncryptionSuiteController.php"] + }, + { + "id": 6, + "num": "2.3", + "title": "Read countUsableForGrantorSuite() before the revoke event clears envelopes; thread it into SUITE_REVOKED audit metadata as emergencyContactsDestroyed and return it (count only); emergency cleared unconditionally, not gated", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["lib/Controller/EncryptionSuiteController.php", "lib/Service/EncryptionSuiteService.php"] + }, + { + "id": 7, + "num": "2.4", + "title": "Widen the AuditEventTypes SUITE_REVOKED metadata whitelist from ['reason'] to ['reason','markCompromised','emergencyContactsDestroyed']", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["lib/Event/Audit/AuditEventTypes.php"] + }, + { + "id": 8, + "num": "3.1", + "title": "New admin 'Encryption suites' settings section under AdminRoot.vue with a force-revoke action (look up by owner/id, required reason, markCompromised toggle) via the encryptionSuite store", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["src/views/settings/AdminRoot.vue", "src/store/modules/encryptionSuite.js"] + }, + { + "id": 9, + "num": "3.2", + "title": "Perform the Nextcloud sudo (password-confirmation) flow before the request", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["src/views/settings/AdminRoot.vue"] + }, + { + "id": 10, + "num": "3.3", + "title": "Render the returned emergencyContactsDestroyed count as an informational warning, and the no-compromise 'user may still know these secrets' copy when markCompromised was off", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["src/views/settings/AdminRoot.vue"] + }, + { + "id": 11, + "num": "3.4", + "title": "Add a reinstate action on the same surface for revoked suites, wired to the existing reinstate() endpoint (frontend-only, no server change, no sudo)", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["src/views/settings/AdminRoot.vue", "src/store/modules/encryptionSuite.js"] + }, + { + "id": 12, + "num": "4.1", + "title": "Endpoint-guard tests: non-admin refused by AuthorizedAdminSetting; PasswordConfirmationRequired posture asserted; suite not revoked", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["tests/Unit/Controller/EncryptionSuiteControllerTest.php"] + }, + { + "id": 13, + "num": "4.2", + "title": "Validation + scope tests: empty/missing reason rejected; an application-owned suite is force-revoked with revokedBy = admin and no vault-key proof", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["tests/Unit/Controller/EncryptionSuiteControllerTest.php"] + }, + { + "id": 14, + "num": "4.3", + "title": "Compromise-cascade test (markCompromised=true): secrets flagged possibly_compromised_at, suite_compromise flags raised, owners notified via secret_compromised", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["tests/Unit/Listener/SuiteCompromiseOnRevokeListenerTest.php"] + }, + { + "id": 15, + "num": "4.4", + "title": "No-cascade test (markCompromised=false): no secret flagged, no rotation flag, warning present in response", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["tests/Unit/Listener/SuiteCompromiseOnRevokeListenerTest.php", "tests/Unit/Controller/EncryptionSuiteControllerTest.php"] + }, + { + "id": 16, + "num": "4.5", + "title": "Emergency + audit test: count read before the cascade, emergency cleared unconditionally (not gated), SUITE_REVOKED metadata carries reason/markCompromised/emergencyContactsDestroyed with count but no identities", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["tests/Unit/Controller/EncryptionSuiteControllerTest.php"] + }, + { + "id": 17, + "num": "4.6", + "title": "Frontend unit test: collects reason + markCompromised, runs sudo before the request, renders count and (when applicable) the no-compromise warning", + "status": "done", + "spec_ref": "openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation", + "acceptance_criteria": [], + "files_likely_affected": ["tests/store/encryptionSuite.spec.js"] + }, + { + "id": 18, + "num": "5.1", + "title": "Run hydra gates locally: route-auth / semantic-auth, no-admin-idor (admin-guarded like reinstate()), gate-16 spec-coverage, route-reachability", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 19, + "num": "5.2", + "title": "Confirm gate-110 does not apply: no migration, no new column, no bump (revoked_reason reused, markCompromised transient)", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + } + ] +} diff --git a/openspec/changes/admin-suite-revocation/proposal.md b/openspec/changes/admin-suite-revocation/proposal.md new file mode 100644 index 000000000..e86c47f21 --- /dev/null +++ b/openspec/changes/admin-suite-revocation/proposal.md @@ -0,0 +1,36 @@ +--- +kind: code +--- + +## Why + +Owner-initiated suite revocation now requires a verified vault-key proof (`harden-vault-key-material-guards`, #673): the caller signs a challenge with the suite's own private key. An administrator cannot produce that proof — the vault is zero-knowledge and the server never holds a usable private key — yet administrators must still be able to revoke a suite they do not own in three real situations: a forgotten master password (the user is locked out and, because #673 blocks a proofless rotation, admin revocation is the *only* way back to a working vault), a de-authorised departure (access must be pulled though the user may still know the secrets), and a compromise (the private key or master password is in an attacker's hands). Application-owned suites have no human owner who can produce a proof at all, so administrator revocation is their only revocation path. See ADR-005. + +## What Changes + +- Add `POST /api/v1/suites/{id}/force-revoke`, an administrator endpoint that revokes **any** EncryptionSuite by id (user- or application-owned) — the admin counterpart to the owner vault-key-proof `revoke()`. It reuses the owner-agnostic `EncryptionSuiteService::revokeSuite()`, recording the administrator as `revokedBy`. +- Guard it with `#[AuthorizedAdminSetting(AdminSettings::class)]` (administrator only, mirroring the existing admin-only `reinstate()`) **and** `#[PasswordConfirmationRequired]` (Nextcloud sudo mode — the administrator re-confirms their **own** password; there is no vault key to prove). This is the app's first use of `PasswordConfirmationRequired`. +- Require a **free-form reason**, stored in the existing `revoked_reason` field (GDPR: the specific "why" must be recordable). +- Add a transient, **non-persisted** request parameter `markCompromised` (default `false`). When `true`, the revoke path flags every secret sealed under the suite (`SecretMapper::findByEncryptionSuiteId`) as `possibly_compromised_at`, raises `suite_compromise` rotation flags (`RotationPolicyService`/`RotationFlagService::flagCompromisedSecrets`), and notifies affected owners (`NotificationService` subject `secret_compromised`) — reusing the existing compromise cascade primitives, adapted to the revoke path. When `false`, no cascade runs and the UI shows a warning that the revoked user may still know these secrets and rotation may be warranted. +- Clear emergency access **unconditionally** (revocation is authoritative), but record the count of destroyed *usable* emergency contacts (`EmergencyEnvelopeInvalidationService::countUsableForGrantorSuite`) in the audit metadata and surface it to the administrator as an informational warning — **not a gate** (unlike the owner path's `acceptEmergencyLoss`). +- Extend the `SUITE_REVOKED` audit event metadata to carry `{ reason, markCompromised, emergencyContactsDestroyed }`. +- Add an admin-side confirmation UI (reason field, compromise toggle, sudo prompt, and the emergency-contact-count warning) in a new admin "Encryption suites" settings section (under `AdminRoot.vue`). +- Add an admin-side **reinstate** action to the same suite-management surface, wired to the existing admin-only `reinstate()` endpoint (`POST /api/v1/suites/{id}/reinstate`) which has no frontend today. This is frontend-only — no server change — and rounds out the revoke/reinstate lifecycle in one place. + +No database migration, no new column, and no `` bump: `revoked_reason` is reused and `markCompromised` is never persisted. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `encryption-suites`: adds a new **Administrator Force-Revocation** requirement — an admin-guarded, sudo-confirmed endpoint that revokes any suite by id with a required reason, an explicit-and-transient compromise decision that drives the existing flag/rotation/notification cascade, and unconditional emergency-access clearing surfaced (not gated) as a count. + +## Impact + +- **Backend**: new `EncryptionSuiteController::forceRevoke()` guarded by `AuthorizedAdminSetting` + `PasswordConfirmationRequired`; a new route in `appinfo/routes.php` before the SPA catch-all; `revokeSuite()` (or a thin admin wrapper) extended to accept the compromise flag and thread `markCompromised` + `emergencyContactsDestroyed` into the audit metadata; the `SUITE_REVOKED` metadata whitelist in `AuditEventTypes` widened from `['reason']` to `['reason', 'markCompromised', 'emergencyContactsDestroyed']`. The compromise cascade reuses `SecretMapper::findByEncryptionSuiteId`, `RotationPolicyService::flagCompromisedSecrets`, and `NotificationService` (`secret_compromised`), adapted to the revoke (no-migration) path rather than the `SuiteMigrationCompletedEvent` path `SuiteCompromiseListener` rides. +- **Frontend**: an admin suite-management surface with two actions — force-revoke (reason input, `markCompromised` toggle, `@conduction/nextcloud-vue` components + NL Design System double-fallback CSS) that performs the Nextcloud sudo (password-confirmation) flow before calling the endpoint and renders the returned emergency-contact-destroyed count, and reinstate (calling the existing admin-only `reinstate()` endpoint; no sudo, no server change). +- **Database**: none. No schema change, no migration, no `` bump — `revoked_reason` is reused and `markCompromised` is transient. +- **Security**: administrator-only + sudo re-authentication; the administrator holds no vault key (zero-knowledge, ADR-003) so a key proof is impossible and sudo mode is the correct re-authentication. The compromise cascade is a deliberate, audited human decision, never derived from free-form text. Emergency-contact identities never cross the wire — only the count. +- **Cross-app**: none directly. OpenConnector application-owned suites gain a first-class, properly guarded revocation path where none existed. diff --git a/openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md b/openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md new file mode 100644 index 000000000..c10a91923 --- /dev/null +++ b/openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: Administrator Force-Revocation + +An administrator MUST be able to revoke any EncryptionSuite by id — user-owned or application-owned — through `POST /api/v1/suites/{id}/force-revoke`, without producing the vault-key proof the owner path requires. The vault is zero-knowledge (see ADR-003): the server never holds a usable private key, so an administrator cannot sign the revoke challenge. Administrator revocation is therefore the *only* revocation path for a locked-out owner (forgotten master password), a de-authorised departure, or a compromise, and the only revocation path of any kind for an application-owned suite, which has no human owner to produce a proof. + +The endpoint MUST be guarded by BOTH: + +- `#[AuthorizedAdminSetting(AdminSettings::class)]` — administrator only, mirroring the existing admin-only `reinstate()`; a non-administrator MUST be rejected by Nextcloud middleware before the controller body runs. +- `#[PasswordConfirmationRequired]` — Nextcloud sudo mode. The administrator re-confirms their **own** account password; there is no vault key to prove. A stale or missing sudo confirmation MUST cause Nextcloud to refuse the request before the controller body runs. + +The endpoint MUST reuse the owner-agnostic `EncryptionSuiteService::revokeSuite()`, which records the acting administrator (resolved via `OCP\IUserSession`) as `revokedBy` and dispatches `EncryptionSuiteRevokedEvent` so the existing destructive cascade (`EncryptionSuiteRevokedListener`) runs: the owner's inbound `ShareTarget`s are deleted, their temporary delegations promoted, and their emergency envelopes cleared. + +The administrator MUST supply a **required, free-form** `reason`. An empty or missing reason MUST be rejected. The reason MUST be stored in the existing `revoked_reason` `STRING(255)` column — no new column and no migration. + +Whether the revoked suite's secrets are treated as **compromised** MUST be an explicit, transient request parameter `markCompromised` (default `false`), carried only in the request and **never persisted** as a suite column. The compromise decision is a human, situational judgment, and MUST NOT be derived from the free-form reason text. + +The `SUITE_REVOKED` audit event's metadata MUST carry `{ reason, markCompromised, emergencyContactsDestroyed }`; the `AuditEventTypes` metadata whitelist for `SUITE_REVOKED` MUST permit those three keys. + +#### Scenario: Administrator force-revokes a locked-out owner's suite (forgotten password) + +- **GIVEN** a user is locked out of their vault (forgotten master password) and cannot produce a vault-key proof +- **WHEN** an authenticated administrator, having passed sudo confirmation, calls `POST /api/v1/suites/{id}/force-revoke` with a non-empty `reason` and `markCompromised=false` +- **THEN** the suite MUST be revoked with `revokedBy` set to the administrator and `revoked_reason` set to the supplied reason +- **AND** no compromise cascade MUST run (secrets MUST NOT be flagged `possibly_compromised_at` and no `suite_compromise` rotation flags MUST be raised) +- **AND** the user MUST be able to re-onboard with a fresh suite through the existing onboarding flow + +#### Scenario: Administrator de-authorises a departing user without marking compromise + +- **GIVEN** a departing user whose secrets are long, generated, non-memorable passwords +- **WHEN** the administrator force-revokes the user's suite with `markCompromised=false` +- **THEN** the suite MUST be revoked and no compromise cascade MUST run +- **AND** the response MUST surface a warning that the revoked user may still know these secrets and that rotation may be warranted, leaving the rotation decision to the administrator + +#### Scenario: Administrator marks the revocation as a compromise + +- **GIVEN** a suite whose private key or master password is believed to be in an attacker's hands +- **WHEN** the administrator force-revokes the suite with `markCompromised=true` +- **THEN** every secret sealed under the suite (`SecretMapper::findByEncryptionSuiteId`) MUST be flagged `possibly_compromised_at` +- **AND** `suite_compromise` rotation flags MUST be raised for those secrets via `RotationPolicyService`/`RotationFlagService::flagCompromisedSecrets` (idempotent) +- **AND** the affected owners MUST be notified with the existing `secret_compromised` notification subject +- **AND** the cascade MUST run on the revoke path itself (scoped to the revoked suite), not via a `SuiteMigrationCompletedEvent` + +#### Scenario: A non-administrator is refused + +- **GIVEN** an authenticated non-administrator user +- **WHEN** they call `POST /api/v1/suites/{id}/force-revoke` on any suite id +- **THEN** Nextcloud's `AuthorizedAdminSetting` guard MUST reject the request before the controller body runs +- **AND** the suite MUST NOT be revoked + +#### Scenario: Sudo confirmation is required + +- **GIVEN** an administrator whose password-confirmation (sudo) window has expired +- **WHEN** they call `POST /api/v1/suites/{id}/force-revoke` +- **THEN** the `PasswordConfirmationRequired` guard MUST refuse the request until the administrator re-confirms their own account password +- **AND** the suite MUST NOT be revoked until sudo is satisfied + +#### Scenario: A missing reason is rejected + +- **GIVEN** an administrator who has passed the admin and sudo guards +- **WHEN** they call `POST /api/v1/suites/{id}/force-revoke` with an empty or missing `reason` +- **THEN** the request MUST be rejected and the suite MUST NOT be revoked + +#### Scenario: An application-owned suite is force-revoked + +- **GIVEN** an application-owned EncryptionSuite (id `00000000-0000-0000-0000-000000000000`) that has no human owner able to produce a vault-key proof +- **WHEN** an administrator force-revokes it with a non-empty `reason` +- **THEN** the same endpoint MUST revoke it via `revokeSuite()`, recording the administrator as `revokedBy` +- **AND** no owner-side vault-key proof MUST be required + +#### Scenario: Emergency access is cleared unconditionally and its count audited, not gated + +- **GIVEN** a suite with one or more usable emergency contacts +- **WHEN** an administrator force-revokes it +- **THEN** the revocation MUST proceed and the emergency access MUST be cleared unconditionally (revocation is authoritative — unlike the owner path, no `acceptEmergencyLoss` gate blocks it) +- **AND** the count of destroyed usable emergency contacts (`EmergencyEnvelopeInvalidationService::countUsableForGrantorSuite`) MUST be recorded in the `SUITE_REVOKED` audit metadata as `emergencyContactsDestroyed` and surfaced to the administrator as an informational warning +- **AND** the emergency contacts' identities MUST NOT cross the wire — only the count diff --git a/openspec/changes/admin-suite-revocation/tasks.md b/openspec/changes/admin-suite-revocation/tasks.md new file mode 100644 index 000000000..de47b407c --- /dev/null +++ b/openspec/changes/admin-suite-revocation/tasks.md @@ -0,0 +1,53 @@ +## 0. Read First — Scope and Constraints + +Scope is the administrator force-revoke endpoint (ADR-005). No database migration, no new column, no `` bump: `reason` reuses the existing `revoked_reason` column and `markCompromised` is a transient request parameter, never persisted. The owner path (`revoke()` with the vault-key proof) MUST stay untouched. This is the app's first use of `#[PasswordConfirmationRequired]`. + +## 1. Backend — Endpoint and Guards + +- [x] 1.1 Add `EncryptionSuiteController::forceRevoke(string $id, string $reason, bool $markCompromised = false)` guarded by `#[AuthorizedAdminSetting(AdminSettings::class)]` (mirroring the existing `reinstate()`) AND `#[PasswordConfirmationRequired]`; resolve the acting administrator via `OCP\IUserSession` and record it as `revokedBy`. It MUST reuse `EncryptionSuiteService::revokeSuite()` and MUST NOT call `validateOwnership()` (cross-owner admin action; authorization is the admin guard) +- [x] 1.2 Register `POST /api/v1/suites/{id}/force-revoke` (`encryptionSuite#forceRevoke`) in `appinfo/routes.php` alongside the other suite routes and before the SPA catch-all wildcard +- [x] 1.3 Reject an empty or missing `reason` with `STATUS_BAD_REQUEST`; on success store it in the existing `revoked_reason` field (no new column). Handle the same `RuntimeException`/`InvalidArgumentException` mapping the owner `revoke()` uses + +## 2. Backend — Compromise Cascade and Audit + +- [x] 2.1 Add a `compromised` flag to `EncryptionSuiteRevokedEvent` and a `bool $markCompromised = false` param to `revokeSuite()` that sets it on the dispatched event; add a new `SuiteCompromiseOnRevokeListener` (sibling to `EncryptionSuiteRevokedListener` on the same event) that reacts only when the flag is true — walk `SecretMapper::findByEncryptionSuiteId($id)`, stamp `possibly_compromised_at`, raise `suite_compromise` flags via `RotationPolicyService::flagCompromisedSecrets` (idempotent), and notify affected owners with the `secret_compromised` subject. The owner `revoke()` path passes no flag, so the listener no-ops there +- [x] 2.2 When `markCompromised === false`, run no cascade and return the "user may still know these secrets; rotation may be warranted" warning in the response for the UI to surface +- [x] 2.3 Read `EmergencyEnvelopeInvalidationService::countUsableForGrantorSuite($id)` BEFORE the `EncryptionSuiteRevokedEvent` cascade clears the envelopes; thread the integer into the `SUITE_REVOKED` audit metadata as `emergencyContactsDestroyed` and return it in the response (count only — never contact identities). Emergency access is cleared unconditionally; it MUST NOT be gated +- [x] 2.4 Widen the `AuditEventTypes` metadata whitelist for `SUITE_REVOKED` from `['reason']` to `['reason', 'markCompromised', 'emergencyContactsDestroyed']`, and emit all three keys from the force-revoke path + +## 3. Frontend — Admin Confirmation UI + +- [x] 3.1 Add a new admin "Encryption suites" settings section (a view under `AdminRoot.vue`, alongside `AdminApplicationsView`/`AdminAuditSection`) with a force-revoke action: look up a suite by owner/id, a required reason field and a `markCompromised` toggle, calling the new endpoint via the encryptionSuite store/module +- [x] 3.2 Perform the Nextcloud sudo (password-confirmation) flow before issuing the request, since the endpoint carries `#[PasswordConfirmationRequired]` +- [x] 3.3 Render the returned `emergencyContactsDestroyed` count as an informational warning and, when `markCompromised` was off, the "user may still know these secrets" copy; use `@conduction/nextcloud-vue` components + the NL Design System double-fallback CSS pattern +- [x] 3.4 Add a reinstate action on the same surface, shown only for suites in `revoked` status, wired to the existing admin-only `reinstate()` endpoint (`POST /api/v1/suites/{id}/reinstate`) — frontend-only, no server change, no sudo + +## 4. Tests + +- [x] 4.1 Endpoint-guard tests: a non-administrator is refused by `AuthorizedAdminSetting`; the `PasswordConfirmationRequired` posture is asserted; the suite is not revoked in either case +- [x] 4.2 Request-validation and scope tests: an empty/missing `reason` is rejected; an application-owned suite is force-revoked by the same endpoint with `revokedBy` = the administrator and no vault-key proof required +- [x] 4.3 Compromise-cascade test (`markCompromised=true`): secrets from `findByEncryptionSuiteId` are flagged `possibly_compromised_at`, `suite_compromise` flags are raised, owners are notified via `secret_compromised` +- [x] 4.4 No-cascade test (`markCompromised=false`): no secret is flagged, no rotation flag raised, and the warning is present in the response +- [x] 4.5 Emergency-and-audit test: the usable-contact count is read before the cascade, emergency access is cleared unconditionally (not gated), and `SUITE_REVOKED` metadata carries `{ reason, markCompromised, emergencyContactsDestroyed }` with the count but no identities +- [x] 4.6 Frontend unit test: the action collects reason + `markCompromised`, runs the sudo flow before the request, and renders the returned count and (when applicable) the no-compromise warning + +## 5. Gates and Documentation + +- [x] 5.1 Run the hydra gates locally: route-auth and semantic-auth (the new admin+sudo-guarded route), no-admin-idor (the method is admin-guarded like `reinstate()`, not `NoAdminRequired`), gate-16 spec-coverage (`@spec` on the new backend + frontend methods), route-reachability (route ↔ method) +- [x] 5.2 Confirm no migration and no `` bump apply (gate-110 does not apply): `revoked_reason` is reused, `markCompromised` is transient, no new column + +## Acceptance Criteria + +- `POST /api/v1/suites/{id}/force-revoke` revokes any suite by id (user- or application-owned), guarded by `AuthorizedAdminSetting` + `PasswordConfirmationRequired`, recording the administrator as `revokedBy` +- A non-empty `reason` is required and stored in the existing `revoked_reason` column; no new column and no migration are introduced +- `markCompromised=true` flags every secret from `findByEncryptionSuiteId`, raises `suite_compromise` rotation flags, and notifies affected owners; `markCompromised=false` runs no cascade and surfaces the rotation-may-be-warranted warning +- Emergency access is cleared unconditionally; the destroyed-usable count is in the `SUITE_REVOKED` audit metadata and the response, and contact identities never cross the wire +- `markCompromised` is never persisted as a suite column; the owner `revoke()` path is unchanged + +## Quality Checklist + +- Unit tests cover the guards, required reason, application-suite scope, both compromise branches, and the emergency-count/audit behaviour +- `@spec` tags reference this change on the new backend and frontend methods; every changed method is spec-covered +- Frontend uses `@conduction/nextcloud-vue` + NL Design System double-fallback CSS, consistent with the settings surface +- Every commit carries `Assisted-by: ClaudeCode:`; no `Signed-off-by` (only the human certifies the DCO) +- PR description discloses AI tool use in the contributor's own words and links ADR-005 and the `harden-vault-key-material-guards` change this is the administrator counterpart to diff --git a/openspec/changes/adopt-connection-registry/.openspec.yaml b/openspec/changes/adopt-connection-registry/.openspec.yaml new file mode 100644 index 000000000..a40cb63c1 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/adopt-connection-registry/design.md b/openspec/changes/adopt-connection-registry/design.md new file mode 100644 index 000000000..faffda697 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/design.md @@ -0,0 +1,83 @@ +# Design: adopt-connection-registry + +The contract is hydra `openspec/changes/connection-registry/design.md` (hydra#667, amended in hydra#673, hydra#674 and hydra#676). This file records how Keepiq meets it and where it fits loosely. + +## D1. Which connections are declared + +Each candidate was checked against the code on `development` on 2026-09-14. + +| Key | Declared as | Why | +|---|---|---| +| `hibp` | `switch: {"configKey": "breach_check_enabled"}` | `BreachProxyController::range()` refuses every lookup with 403 while the key is off. Nothing else gates the call. | +| `siem` | `reportedOnly: true` | `SiemService::deliverDue()` drains every enabled sink in `keepiq_siem_sinks`. Sinks are records, not app config. | + +**Why a boolean key is honest here.** `AdminSettingsService` stores `breach_check_enabled` with `setValueBool`. Integriq's `ConnectionConfigReader::readAnyType()` reads a typed key with `getValueBool`, and since hydra#676 a `false` counts as empty. Since hydra#677 the key is the row's `switch`, not a required setting: a switched-off check reads `disabled` with the declared `disabledMessage`, and a switched-on one reads Not configured with "Not checked yet" until the first lookup reports. A filled switch says the check may run, not that Have I Been Pwned answered. + +**Why no adapter on `hibp`.** The upstream is a fixed constant, `https://api.pwnedpasswords.com/range/`. There is no mock to select, so rule 3 has nothing to read. + +**Why one row for every sink.** A static file cannot list records an admin adds at runtime. D12 names SIEM sinks as the example of a family, and says to declare one row and report on it. + +**Anchors.** The admin section id is `keepiq` (`Sections\SettingsSection`, bound to OpenRegister's `GenericSettingsSection`), so each link is `/settings/admin/keepiq#section-...`. `BreachCheckSection.vue` and `SiemSection.vue` put the id on their `CnSettingsSection`, which passes it to the `NcSettingsSection` root through `v-bind="$attrs"`. + +**No vault data.** A row holds a status, a message and a host. It holds no secret, entry name, folder or user id, so it stays inside the `integration-boundary` capability, which forbids vault data in OpenRegister objects. + +## D2. What Keepiq reports, and when + +`lib/Service/Connection/ConnectionReporter.php` sends both events. It names the classes by string behind `class_exists` (ADR-041) and never throws. `lib/Service/Connection/ConnectionObservations.php` maps an outcome to a status and a message. It is pure, so every mapping is testable without a double. + +**Breach check, on an admin settings save** (`PUT /api/settings/admin` with `breach_check_enabled`). A refresh for `hibp` and no report. Integriq reads the switch itself. + +**Breach check, after a range lookup that reached the upstream.** A cache hit makes no call and reports nothing. The 401, 403 and 400 answers happen before any call and report nothing. + +| The upstream | Status | Message | +|---|---|---| +| answered 2xx | `configured` | "The last range lookup reached Have I Been Pwned." | +| answered 429 | `limited` | "Have I Been Pwned limited the last range lookup (HTTP 429)." | +| answered anything else | `error` | "Have I Been Pwned answered HTTP {n} on the last range lookup." | +| did not answer | `error` | "The last range lookup got no answer from Have I Been Pwned." | + +**SIEM, on a sink create, change or delete.** A refresh for `siem`. When no enabled sink is left and sinks still exist, a report `disabled`: "Every SIEM sink is switched off, so no audit event is forwarded." When no sink exists at all, a report `unconfigured`: "No SIEM sink is added yet. Add one under SIEM audit export." The row has no `switch`, because sinks are records, so Keepiq reports `disabled` itself (hydra connection-registry D4). All sinks are counted only when none is enabled. Otherwise the refresh alone, so the row reads the declared "Not checked yet" until the next drain delivers. + +**SIEM, after a drain** (`DeliverSiemEventsJob`, every 60 seconds). The report looks only at sinks the drain tried to deliver to in this run. A sink's older `lastDeliveryStatus` is not used: it would bring back an error from before a save, which is exactly what hydra#674 retires. + +| Sinks the drain delivered to | Status | +|---|---| +| none, no sink is enabled, and sinks exist | `disabled` | +| none, and no sink exists | `unconfigured` | +| none, while sinks are enabled | nothing | +| all took it | `configured` | +| some took it | `limited`, naming the first host that failed | +| none took it | `error`, naming the first host that failed | + +**What a message may carry.** The breach check reporter takes only an HTTP status, as `?int`. A hash prefix cannot reach it by type. The SIEM mapper takes a host, derived with `parse_url(..., PHP_URL_HOST)`: a webhook URL as is, a syslog `host:port` behind `tcp://`. A value with no host is left out of the message, which then reads "The SIEM sink took the last delivery." Neither ever reads an exception message, because a Guzzle exception names the full request URL, and on a range lookup that URL ends in the prefix. + +**Throttle.** The reporter remembers the last status and time per key in the app-config key `connection_report_{key}`. The same status goes out again after an hour. A different status waits five minutes after the last report, so an upstream that flips cannot report on every lookup. A save deletes the memory for its key, so the first outcome after a save is reported at once. The copy is the one buildiq#777 uses. + +**Why this is cheap (ADR-076).** A lookup reports only on a cache miss, and then reads one in-memory app-config value. The drain runs from cron, never from a page request. A save is an admin action. + +**Wiring.** `SettingsController` is built by hand, because `DomainOverrideRegistrar::register()` (called from `Application::register()`) overrides the AppHost alias. The build moved into `lib/AppInfo/SettingsControllerFactory.php`, which passes the reporter by name; left in the registrar, the extra dependency took its coupling to 13, over the PHPMD limit. `BreachProxyController`, `SiemService` and `SiemSinkService` are autowired, so each takes the reporter as an optional last argument. + +## D3. The page + +- `src/manifest.d/80-connection-registry.json`: an `index` page `Integrations` at `/settings/integrations`, `requiresApp` integriq, `permission: admin`, `showAdd: false`, and the columns connection, status, status message, last checked and settings. +- Its menu entry `IntegrationsMenu` sits in the settings gear with `query: {app: keepiq}`, `permission: admin` and `visibleIf.appInstalled: integriq`. +- `src/services/connectionRegistry.js` holds `openIntegriqConnections`. +- `App.vue` passes no `formatters`, because CnAppRoot supplies the two built-ins, and merges the handler into the `customComponents` it passes, because CnIndexPage resolves a header action's handler against `customComponents`. + +**Keepiq's own navigation rail.** Keepiq renders `KeepiqAppNav` in CnAppRoot's `#menu` slot, because CnAppNav cannot draw the vault folder tree. That rail read only `route`, `href` and `action`. It dropped `query`, so the menu would have opened the page with no preset and listed every app's rows. It also ignored `permission` and `visibleIf`, so the entry would have shown to every user and without integriq. `src/utils/navEntries.js` now holds both rules, taken from CnAppNav: `menuEntryTo()` passes `query` into the route, and `isMenuEntryVisible()` checks `visibleIf.appInstalled` against `OC.appswebroots` and `permission: admin` against the instance admin flag. No existing entry declares either field, so nothing else in the rail changes. + +**Why `/settings/integrations` does not break ADR-004.** The rule forbids routing an admin settings component, such as `AdminRoot.vue`, inside the app. This route renders a CnIndexPage over integriq's `app_connection`, whose schema grants read access to admins only. The admin settings themselves stay in `AdminSettings.php`. The `hydra-gate-admin-router` check reads `src/router/index.js`, which Keepiq does not have: routes come from the manifest. + +**Formatters.** `@conduction/nextcloud-vue` 3.2.0 ships `connectionStatus` and `connectionSettingsLabel` as built-ins, `disabled` included (nextcloud-vue#1173). Keepiq carried a local copy while it pinned 2.41.1, and dropped it on moving to 3.2.0. + +## D4. Contract misfits + +- **The navigation slot.** D8 assumes CnAppNav reads the menu entry. An app that fills CnAppRoot's `#menu` slot with its own rail gets none of `query`, `permission` or `visibleIf` for free. Keepiq fixed its rail. Other apps with a custom rail need the same check. +- **A family row with a test button.** SIEM has a per-sink test-fire. Its outcome says nothing about the other sinks, so it does not report. The contract has no per-record status. +- **A report that needs a user action.** `hibp` reports only when a user runs a check. On an instance where nobody checks, the row keeps "Required settings are filled." indefinitely. Rule 5 claims only what integriq can see, so the message stays honest, but it never proves the upstream answers. +- **The vault lock.** Every routed Keepiq page, this one included, sits behind the master password. An admin with a locked vault meets the lock screen before the Integrations page. + +## Risks + +- **Same-second ordering.** A sink save sends the refresh before the report. Hydra#674 compares with "not older than", so an equal stamp counts. +- **A SIEM row can lag.** A drain with nothing queued reports nothing, so the row keeps the last outcome until an event is forwarded. diff --git a/openspec/changes/adopt-connection-registry/proposal.md b/openspec/changes/adopt-connection-registry/proposal.md new file mode 100644 index 000000000..6fce9ff3c --- /dev/null +++ b/openspec/changes/adopt-connection-registry/proposal.md @@ -0,0 +1,46 @@ +--- +kind: code +--- + +# Proposal: adopt-connection-registry + +## Why + +Keepiq talks to two outside systems. Today an admin can only tell whether they work by reading two settings sections and the server log. + +- **Have I Been Pwned.** When an admin switches on breach checking, users can check their passwords. Keepiq sends a 5-character SHA-1 hash prefix to `api.pwnedpasswords.com`. A failing upstream shows only as a warning in the log. +- **SIEM audit export.** Keepiq forwards whitelisted audit events to syslog or webhook sinks. A background job drains the queue every minute. There can be many sinks, and each one is a record in Keepiq's own table. + +Hydra change `connection-registry` (hydra#667, amended in hydra#673, hydra#674 and hydra#676) gives every app one page of its connections, backed by integriq. + +## What changes + +- New `lib/Settings/connections.json` with two connections: `hibp` and `siem`. +- `hibp` declares `breach_check_enabled` as its `switch`. The key is a boolean, and integriq reads a stored `false` as empty (amendment 6), so a switched-off check reads Switched off (amendment 9). +- `siem` is `reportedOnly`. The sinks are records, not settings, so a static file cannot list them. One row speaks for the whole family (D12, "Still out"). +- The Breach checking and SIEM audit export sections get stable ids: `section-breach-check` and `section-siem`. +- Saving `breach_check_enabled` sends `ConnectionRefreshRequestedEvent` for `hibp`. +- Creating, changing or deleting a sink sends a refresh for `siem`, then reports Switched off when sinks exist and none is on, or Not configured when there is no sink. +- A range lookup that reaches Have I Been Pwned reports its outcome. A SIEM drain that delivered to at least one sink reports the outcome over those sinks. Both are throttled: the same status at most once an hour, a new status at most once every five minutes. +- A report names a status code or a host. It never carries a hash prefix, a password, a sink URL path, a token or an exception message. +- An Integrations page under the settings gear, over integriq's `app_connection` schema, preset to `app=keepiq`, admin only, and only shown when integriq is installed. +- Keepiq's own navigation rail learns to honour a menu entry's `query`, `permission: admin` and `visibleIf.appInstalled`. It ignored all three before, so the preset would not have reached the page. +- Add integration opens `/apps/integriq/connections?app=keepiq&link=1`. +- The `connectionStatus` and `connectionSettingsLabel` formatters come from `@conduction/nextcloud-vue` 3.2.0, which labels all seven statuses. The page strings are in English and Dutch. + +## Depends on + +- hydra `openspec/changes/connection-registry`, design D2, D3, D4, D6, D8, D9 and D12 amendments 1 to 7. +- integriq on `development`: the `app_connection` schema, the declaration sync, both events and the Connections overview. + +Without integriq the menu entry is hidden, a deep link shows the missing-dependency screen, and nothing is sent. + +## Out of scope + +- The SIEM test-fire button. It tests one sink, and the row speaks for all of them. Its result already shows in the SIEM section. +- The CA certificate renewal and the browser extension relay. Both stay inside the instance. +- Exempting the Integrations page from the vault lock. Every routed Keepiq page sits behind the master password, and this change keeps that rule. + +## Rollback + +Revert the change. Keepiq writes no rows of its own. Integriq removes the rows without a linked source on its next sync. The two `connection_report_*` app-config keys can stay: nothing else reads them. diff --git a/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md b/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md new file mode 100644 index 000000000..8be3660a7 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md @@ -0,0 +1,139 @@ +# admin-integrations Specification Delta + +**Status**: proposed +**Scope**: keepiq +**OpenSpec changes**: +- [adopt-connection-registry](../../) + +## Purpose + +Admins see Keepiq's outside connections on one page, with a status Keepiq can back. + +## ADDED Requirements + +### Requirement: REQ-KEEPIQ-CONN-001 Keepiq declares its outside connections in one static file + +Keepiq SHALL declare `hibp` and `siem` in `lib/Settings/connections.json` in the shape of hydra connection-registry design D2 (hydra REQ-CONN-001). The file MUST validate against integriq's `connections.schema.json`, and its `app` MUST equal the id in `appinfo/info.xml`. The `hibp` entry SHALL declare `breach_check_enabled` as its `switch` and SHALL require no setting, so a switched-off check reads `disabled` (hydra connection-registry D12 item 9). The `siem` entry SHALL be `reportedOnly`, because the sinks are records and not settings. Every `settingsUrl` SHALL point at a section id that exists in the Keepiq admin settings. + +#### Scenario: The declaration names this app and passes integriq's schema +@e2e exclude A static file with no browser surface; tests/Unit/Settings/ConnectionsDeclarationTest.php validates it against the schema, and checks the app id, unique keys and the anchors. + +- **GIVEN** `lib/Settings/connections.json` +- **WHEN** it is validated against integriq's `connections.schema.json` +- **THEN** it SHALL validate +- **AND** its `app` SHALL equal the id in `appinfo/info.xml` +- **AND** every key SHALL be unique +- **AND** every `#section-...` anchor SHALL be an id in a settings section component + +#### Scenario: A switched-off breach check reads switched off +@e2e tests/e2e/workflows/integrations-page.spec.ts + +- **GIVEN** integriq has synced Keepiq's declaration +- **WHEN** `breach_check_enabled` holds `false` +- **THEN** the Breach check row SHALL read `disabled` with the declared message +- **AND** when an admin switches breach checking on, the row SHALL read Not configured with "Not checked yet" until a lookup reports + +### Requirement: REQ-KEEPIQ-CONN-002 A save asks integriq to look again, and a lookup or a drain reports what it met + +When an admin save writes `breach_check_enabled`, Keepiq SHALL send `ConnectionRefreshRequestedEvent` with app `keepiq` and key `hibp`. When an admin creates, changes or deletes a SIEM sink, Keepiq SHALL send a refresh for `siem`. When no sink is switched on afterwards, it SHALL then report `disabled` while sinks exist, and `unconfigured` when none does. A `disabled` report SHALL name no host. A refresh SHALL come before any report for the same key (hydra REQ-CONN-004, hydra#674). A range lookup that reaches Have I Been Pwned SHALL report `configured` on a 2xx answer, `limited` on HTTP 429 and `error` on any other answer or no answer. A SIEM drain SHALL report over the sinks it delivered to in that run: all took it as `configured`, some as `limited`, none as `error`. A drain that delivered to no sink SHALL report nothing. The same status SHALL be reported at most once an hour and a new status at most once every five minutes, and a save SHALL clear that memory. Both events SHALL be named by string and sent only when the class exists. Neither SHALL change the response of the request, job or run that sent it. + +#### Scenario: Saving the breach check switch asks for a refresh +@e2e exclude The event is not observable from a browser; tests/Unit/Controller/SettingsControllerConnectionRefreshTest.php asserts the refresh and the unchanged response. + +- **GIVEN** integriq is installed +- **WHEN** an admin saves the admin settings with `breach_check_enabled` +- **THEN** Keepiq SHALL send a refresh for `hibp` +- **AND** a save without that key SHALL send nothing + +#### Scenario: Deleting the last sink refreshes, then reports +@e2e exclude A sink change needs a SIEM receiver the CI instance does not have; tests/Unit/Service/Connection/ConnectionReporterTest.php asserts the order, and tests/Unit/Service/SiemConnectionReportCallersTest.php that the sink service hands it over. + +- **GIVEN** integriq is installed +- **WHEN** an admin deletes the only SIEM sink +- **THEN** Keepiq SHALL send a refresh for `siem` +- **AND** then a report `unconfigured` saying no sink is added yet + +#### Scenario: Switching off the last enabled sink reports disabled +@e2e exclude A sink change needs a SIEM receiver the CI instance does not have; tests/Unit/Service/SiemConnectionReportCallersTest.php and tests/Unit/Service/Connection/ConnectionReporterTest.php assert the refresh, the `disabled` report and its host-free message. + +- **GIVEN** integriq is installed and two SIEM sinks exist +- **WHEN** an admin switches off the last one that was enabled +- **THEN** Keepiq SHALL send a refresh for `siem` +- **AND** then a report `disabled` that names no host + +#### Scenario: A drain where some sinks fail reads limited +@e2e exclude A drain needs reachable and unreachable receivers; tests/Unit/Service/Connection/ConnectionObservationsTest.php drives the outcomes. + +- **GIVEN** two enabled sinks with queued events +- **WHEN** the drain delivers to one and fails on the other +- **THEN** Keepiq SHALL report `siem` as `limited` +- **AND** the message SHALL name the failing sink's host + +#### Scenario: A repeated outcome is not reported on every lookup +@e2e exclude The throttle is a time window; tests/Unit/Service/Connection/ConnectionReporterTest.php drives the clock. + +- **GIVEN** a lookup reported `configured` a minute ago +- **WHEN** another lookup answers 200 +- **THEN** Keepiq SHALL send no report +- **AND** a lookup that fails five minutes after the last report SHALL report `error` + +#### Scenario: Without integriq nothing is sent +@e2e exclude The CI instance installs integriq; tests/Unit/Service/Connection/ConnectionReporterTest.php asserts nothing is sent, read or logged when the class is absent. + +- **GIVEN** integriq is not installed +- **WHEN** an admin saves the breach check switch, a lookup runs or a drain runs +- **THEN** no event SHALL be sent and nothing SHALL be logged +- **AND** the save, lookup or drain SHALL answer as it did before this change + +### Requirement: REQ-KEEPIQ-CONN-003 A report names a status code or a host, and nothing a user typed + +A connection report is read by every admin, and integriq stores it in OpenRegister. A breach check report SHALL carry no hash prefix, no password, no hash suffix and no exception text. Only the HTTP status of the upstream answer SHALL reach it. A SIEM report SHALL name a sink by its host only, never by its URL path, query, user info or token, and SHALL never carry a delivery error text. No report SHALL carry vault data: no entry name, folder, user id or secret (integration-boundary). + +#### Scenario: A failed lookup reports no part of the lookup +@e2e exclude The prefix only exists inside one request; tests/Unit/Controller/BreachProxyControllerConnectionReportTest.php sends a known prefix through a failing and a passing upstream and reads every event. + +- **GIVEN** a user checks a password whose hash starts with `A1B2C` +- **WHEN** the upstream call fails with an exception naming the full range URL +- **THEN** Keepiq SHALL report `error` +- **AND** no report message SHALL contain `A1B2C`, in any letter case, or the upstream URL + +#### Scenario: A sink with a token in its URL is named by host +@e2e exclude Needs a failing webhook receiver; tests/Unit/Service/Connection/ConnectionObservationsTest.php feeds URLs with user info, paths and tokens. + +- **GIVEN** a webhook sink at `https://user:s3cret@siem.gemeente.example/ingest?token=abc` +- **WHEN** a drain fails to deliver to it +- **THEN** the report SHALL name `siem.gemeente.example` +- **AND** it SHALL contain none of `s3cret`, `user`, `/ingest` or `token` + +### Requirement: REQ-KEEPIQ-CONN-004 An admin reads the connections on an Integrations page + +Keepiq SHALL render an `index` page at `/settings/integrations` over `integriq/app_connection`, reached from the settings gear and preset to `app` equal to `keepiq` through its menu entry's `query` (hydra REQ-CONN-006). The page and its menu entry SHALL be admin only. The page SHALL require Integriq, and the menu entry SHALL only render when integriq is installed. Keepiq's navigation rail SHALL pass the entry's `query` into the route and SHALL honour `permission: admin` and `visibleIf.appInstalled`. The status column SHALL name all seven statuses, `limited` and `disabled` included, through the `connectionStatus` formatter `@conduction/nextcloud-vue` ships. The page SHALL NOT offer a generic Add button. Its Add integration action SHALL open `/apps/integriq/connections?app=keepiq&link=1`. + +#### Scenario: The page lists only the rows of keepiq +@e2e tests/e2e/workflows/integrations-page.spec.ts + +- **GIVEN** Keepiq and integriq are installed and integriq has synced the declaration +- **WHEN** an admin opens the Integrations page from the settings gear +- **THEN** the page SHALL list the two declared connections +- **AND** every listed row SHALL have `app` equal to `keepiq` + +#### Scenario: Add integration goes to integriq +@e2e tests/e2e/workflows/integrations-page.spec.ts + +- **GIVEN** the Integrations page +- **WHEN** the admin chooses Add integration +- **THEN** the browser SHALL open integriq's Connections overview with `app=keepiq` and `link=1` + +#### Scenario: The menu entry hides without integriq and from non-admins +@e2e exclude The CI instance always installs integriq and the e2e user is an admin; tests/vitest/navEntries.spec.js drives both conditions. + +- **GIVEN** the Integrations menu entry +- **WHEN** integriq is not enabled, or the user is not an instance admin +- **THEN** Keepiq's navigation rail SHALL NOT render the entry + +#### Scenario: A connection that works in part reads Limited +@e2e exclude Only a rate-limited lookup or a partly failing drain produces limited; tests/vitest/connectionRegistry.spec.js asserts the status column uses the library's built-in connectionStatus, whose labels nextcloud-vue's tests/utils/builtInFormatters.spec.js (formatConnectionStatus) asserts, with Beperkt in the library's l10n/nl.json. + +- **GIVEN** a row whose status is `limited` +- **WHEN** the page renders it +- **THEN** the cell SHALL read Limited, or Beperkt on a Dutch instance diff --git a/openspec/changes/adopt-connection-registry/tasks.md b/openspec/changes/adopt-connection-registry/tasks.md new file mode 100644 index 000000000..116c34023 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/tasks.md @@ -0,0 +1,39 @@ +# adopt-connection-registry tasks + +## 1. Declare + +- [x] 1.1 Write `lib/Settings/connections.json` with `hibp` and `siem`. +- [x] 1.2 Give the Breach checking and SIEM audit export sections the ids the file links to. +- [x] 1.3 Guard the file in `tests/Unit/Settings/ConnectionsDeclarationTest.php`, against integriq's schema vendored in `tests/fixtures/Integriq/connections.schema.json`. + +## 2. Page + +- [x] 2.1 Add `src/manifest.d/80-connection-registry.json` with the page and its settings-gear menu entry. +- [x] 2.2 Add `src/services/connectionRegistry.js` with the two formatters and the Add integration handler. +- [x] 2.3 Wire the formatters and the handler in `src/App.vue`; register `PowerPlugOutline` in `src/icons.js`. +- [x] 2.4 Teach `KeepiqAppNav` the menu `query`, `permission: admin` and `visibleIf.appInstalled`, through `src/utils/navEntries.js`. +- [x] 2.5 Add the strings to `l10n/en` and `l10n/nl`. +- [x] 2.6 Cover it in `tests/vitest/connectionRegistry.spec.js` and `tests/vitest/navEntries.spec.js`. + +## 3. Reports and refresh + +- [x] 3.1 Add `lib/Service/Connection/ConnectionObservations.php` and `lib/Service/Connection/ConnectionReporter.php`. +- [x] 3.2 Refresh from the admin settings save in `SettingsController`, and pass the reporter in `DomainOverrideRegistrar`. +- [x] 3.3 Report range lookup outcomes from `BreachProxyController`. +- [x] 3.4 Refresh and report from `SiemSinkService` sink changes, and report drain outcomes from `SiemService::deliverDue()`. +- [x] 3.5 Add the integriq event stubs for PHPUnit, psalm and phpstan. +- [x] 3.6 Cover it in `ConnectionObservationsTest`, `ConnectionReporterTest`, `SettingsControllerConnectionRefreshTest`, `BreachProxyControllerConnectionReportTest` and `SiemConnectionReportCallersTest`. + +## 4. End to end + +- [x] 4.1 Write `tests/e2e/workflows/integrations-page.spec.ts`. +- [x] 4.2 Install integriq in the CI `additional-apps`. + +## 5. Switch and built-in formatters (hydra#677) + +- [x] 5.1 Declare `switch` on `hibp` in place of `requiredConfig`, and report `disabled` when every SIEM sink is switched off. +- [x] 5.2 Move `@conduction/nextcloud-vue` to the release with the built-in connection formatters and delete the local copy. + +## 6. After integriq ships + +- [ ] 6.1 Run the e2e spec against an instance with both apps, then archive this change. diff --git a/openspec/changes/harden-vault-key-material-guards/.openspec.yaml b/openspec/changes/harden-vault-key-material-guards/.openspec.yaml new file mode 100644 index 000000000..1ea7e36f4 --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-09 diff --git a/openspec/changes/harden-vault-key-material-guards/design.md b/openspec/changes/harden-vault-key-material-guards/design.md new file mode 100644 index 000000000..45d2e3ca1 --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/design.md @@ -0,0 +1,162 @@ +# Design — harden-vault-key-material-guards + +## Context + +Under ADR-003 (always-E2E) the server holds ciphertext and an AES-wrapped private key, never the master password. That makes *reading* the vault cryptographically gated. It leaves *writing* gated only by the Nextcloud session, because writing a secret needs nothing but the owner's public key — which is by design. + +Issue #395 shows what that costs on the paths that write **key material** rather than secrets. `EncryptionSuiteController::compromiseRecovery()` accepts an attacker's own keypair and proves nothing about the suite being replaced; `updatePrivateKey()` overwrites the envelope in place behind an ownership check; `MigrationController::complete()` marks the old suite `compromised`; `EmergencyAccessController::destroy()` deletes the recovery envelope. None can be undone: `EncryptionSuiteService::reinstateSuite()` accepts `revoked` and refuses `compromised`, and there is no abort route. + +Keepiq already has both halves of the mechanism this needs, unconnected: + +- **Server**: `lib/Middleware/JwtAuthMiddleware.php` + `PlatformIntegrationRegistrar.php:64` establish the app-middleware pattern (`beforeController` throws, `afterException` renders JSON). `tests/Unit/Controller/RateLimitAttributesTest.php` establishes attribute-coverage testing as a build guard. +- **Client**: `src/crypto/reauth.js` derives the AES key from a freshly entered master password, decrypts the private-key envelope to prove knowledge, and discards every derived key immediately. Its own header states that the control is *advisory* because only the client sees the result. + +This change connects them. + +## Goals / Non-Goals + +**Goals** + +- No irreversible operation on vault contents or key material succeeds without a **server-verified** proof of master-password knowledge +- The guard is declarative and reusable: a future destructive route opts in with one attribute, and forgetting the attribute fails the build +- Every wedged migration has a route back to a working vault (abort) +- No new runtime dependency, no new table, no new cache requirement + +**Non-Goals** + +- Gating *every* write on the master password. See D3 — a blanket rule would be strictly less safe than a targeted one +- Recovering a vault whose owner has genuinely forgotten the master password. Rotation exists for a key that may be *exposed*, not for a password that was *forgotten*; the lost-password route is administrator revocation and is deliberately deferred to a follow-up change +- Defending against a client that keylogs the master-password field. No client-side-rooted E2E system can, and `reauth.js` already says so +- Retrofitting the three existing advisory `verifyMasterPassword()` gates. Follow-up change + +## Decisions + +### D1: The proof is a signature over a server-issued challenge, verified against the stored public key + +`GET /api/v1/suites/{id}/proof-challenge` returns a nonce. The client decrypts the private-key envelope with the freshly entered master password, re-imports the PKCS#8 bytes with `['sign']` usage, signs, discards the key, and sends the signature in `X-Keepiq-Key-Proof`. The middleware verifies it against the suite's stored public key. + +The server can do this because it already holds the public key and the certificate. It cannot verify anything about the *plaintext* — and does not need to. Possession of the private key implies possession of the master password, because the private key exists only inside an AES envelope keyed by PBKDF2-SHA256 over that password. + +This is what closes finding 2 in the proposal. Gating `complete()` alone is insufficient because an attacker can commit garbage ciphertext and complete with zero failures; gating the *entry* to rotation stops every downstream variant, including that one. `complete()` is still guarded, as defence in depth, but it is not where the fix lives. + +### D2: Signature, never decryption — this is load-bearing + +The obvious alternative is a decrypt challenge: the server encrypts a nonce to the suite public key and the client returns the plaintext. **This must not be used.** The session `CryptoKey` (`src/crypto/rsa.js:61-66`) is imported: + +```js +crypto.subtle.importKey('pkcs8', keyData, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, // extractable = false — security critical + ['decrypt'], // decrypt only +) +``` + +Non-extractable, and decrypt-only. So: + +| challenge design | satisfiable by an unlocked tab | satisfiable by XSS in that tab | +|---|---|---| +| "decrypt this nonce" | yes | yes | +| "sign this nonce" | no | no | + +A decrypt challenge is satisfiable by the long-lived session key, which means XSS in an unlocked tab defeats it. Signing requires re-importing the raw PKCS#8 bytes with `['sign']` usage, and those bytes exist only for the instant `decryptPrivateKey()` (`src/crypto/aes.js:79`) returns them — which requires the password. `extractable: false` is precisely what makes the proof unforgeable from a live session, and it only pays off if the proof is a signature. + +Anyone tempted to simplify this later should read this decision first. + +### D3: The guard is a step-up gate on irreversible operations, not a blanket write gate + +Producing a signature requires the raw private key. Gating every write therefore means either a password prompt plus ~1s of PBKDF2 (600k rounds) on every secret created, or holding a signing-capable key in memory for the session. + +The second undoes `extractable: false` and hands XSS the exact capability the guard exists to deny. A blanket rule would make the app **less** safe than a targeted one. The enforceable invariant is therefore: + +> No irreversible operation on vault contents or key material without a server-verified proof of the master password — produced at the moment the user enters it, and discarded immediately. + +Reading is already cryptographically gated and needs nothing added. Creating a secret needs only the public key and stays ungated. + +### D4: The attribute carries the binding, so the middleware stays route-agnostic + +A proof that authorises "some operation" is replayable onto a different operation. Binding the signature to the request is what prevents that — but the middleware cannot see the request body. `Request::decodeContent()` reads `php://input` via `file_get_contents`, `json_decode`s it and **discards the raw string**; `getContent()` is `protected`; and `IRequest`'s entire public surface is `getHeader / getParam / getParams / ...` with no raw-body accessor. Re-reading `php://input` from app code would bypass the injectable `inputStream` the Request is constructed with, making the guard the one part untestable in an isolated PHPUnit run. + +So the attribute declares the binding and the middleware reads named parameters: + +```php +#[VaultKeyProofRequired(binds: ['publicKey', 'encryptedPrivateKey'])] +public function compromiseRecovery(string $publicKey, string $encryptedPrivateKey): JSONResponse + +#[VaultKeyProofRequired(binds: ['encryptedPrivateKey'], subject: 'routeParam:id')] +public function updatePrivateKey(string $id, string $encryptedPrivateKey): JSONResponse +``` + +- `binds` — request parameters the proof commits to, hashed individually in declared order +- `subject` — whose public key verifies: `'active'` (the session user's active suite, default) or `'routeParam:'` + +Signed payload: `nonce || sha256(param_1) || ... || sha256(param_n)`. + +Three things fall out. There is no canonicalisation problem — only named scalar parameters, hashed individually, so `crypto.subtle` and PHP never have to agree on JSON key ordering, number formatting or unicode normalisation. The binding is legible at the route rather than buried in the middleware. And the proof travels as a header, so no guarded controller signature grows a `?string $proof` it never reads. + +### D5: The nonce is stateless, because the binding makes single-use unnecessary + +`nonce = base64(random) . '.' . HMAC(instance secret, random | uid | purpose | exp)`. The middleware verifies the HMAC and the expiry; no storage, no table. + +Replay is not a gap here. Because the signature commits to the operation's parameters, a captured proof only ever re-authorises the byte-identical operation: for `compromiseRecovery` that is the victim's own successor key, for `updatePrivateKey` it is re-setting the envelope already in place. `purpose` binds the challenge to one route, so a proof for one guarded operation cannot be presented to another. + +Deliberately **not** `ICacheFactory`: without a configured distributed cache Nextcloud returns a null cache, and a nonce store that silently forgets would break the flow on a default install. + +### D6: Abort terminates a migration only while nothing has been committed + +The reachable states, given a migration A -> B: + +``` + A ──────────────▶ B n of m records already re-encrypted to B + migration + + abort + revoke B ⇒ those n records unreadable ✗ + abort + keep B active ⇒ the other m-n stranded on A ✗ + abort only while n = 0 ✓ +``` + +The third rule is both defensible and sufficient: an attacker commits nothing, because producing valid re-encrypted ciphertext requires the plaintext and therefore the master password. Once any record has been committed the remedy is resume, not abort, and the refusal names the count. + +Abort sets `aborted`, clears failure accounting, revokes the unused successor suite, leaves the old suite `active`, releases the write lock, and dispatches a new `SuiteMigrationAbortedEvent` so `SuiteMigrationStartedListener`'s locked SecretRequests are released. It **must not** dispatch `SuiteMigrationCompletedEvent` — that is what `EmergencyAccessSuiteRotationListener` consumes to invalidate the recovery envelopes, and abort exists to avoid exactly that loss. + +Abort carries **no** `#[VaultKeyProofRequired]`, deliberately. It is restorative: it returns the vault to the old suite, still `active`. An attacker aborting a victim's legitimate rotation is a nuisance the victim can simply redo, whereas a proof requirement on abort would leave a wedged vault wedged. + +### D7: Coverage is guarded by a test, because attribute guards fail open by omission + +The failure mode of every declarative guard is the route that forgets it: nothing errors, the guard is simply absent. Notably, NC's own `PasswordConfirmationMiddleware` shows the same shape from the inside — `canConfirmPassword()`, the `SCOPE_SKIP_PASSWORD_VALIDATION` token scope and an `excludedUserBackEnds` list for SAML each `return;` and the guard disappears rather than failing. + +`RateLimitAttributesTest` already solves this locally for `#[AnonRateLimit]`: enumerate the routes that must carry an attribute, assert by reflection that each does. `VaultKeyProofAttributesTest` does the same for the destructive list, so a new destructive route without the guard turns the build red. + +Our guard has no equivalent bypass to make: it never consults the auth backend, so it behaves identically on SSO, app-password and ordinary sessions. + +### D8: Verification lives in a service, not in the middleware + +`VaultKeyProofService` owns challenge issuance and signature verification; the middleware owns attribute dispatch, subject resolution, parameter collection and the 403. This keeps the crypto unit-testable without the app framework, and mirrors how `JwtAuthMiddleware` delegates to `JwtAuthService`. + +The 403 body carries `error: 'key_proof_required'` so a client can tell "fetch a challenge and retry" from a dead end, the same way `migration_incomplete` and `migration_in_progress` are already distinguishable. + +## Risks / Trade-offs + +- **`updatePrivateKey` is the hot path.** It is the routine master-password change, so the guard lands on a flow users hit regularly. Mitigated by the fact that the flow already holds the old password in order to re-wrap the envelope — the proof is free at that moment. If the flow is ever changed to derive the new envelope without materialising the old key, the guard breaks; the spec scenario pins this +- **Breaking API change on four routes.** Deliberate, and cheap only because the app is pre-production. Any out-of-tree client of those routes must be updated +- **A user who has forgotten the master password can no longer rotate.** This is the correct behaviour, not a regression — but it means the lost-password route (administrator revocation, with the emergency-access warnings from #395) is now load-bearing and must not be deferred indefinitely +- **PBKDF2 cost on the guarded flows.** ~1s per proof at 600k rounds. Acceptable on operations a user performs a handful of times; unacceptable per-write, which is D3 +- **Proof of possession is not proof of intent.** A user tricked into typing their master password into a hostile flow still produces a valid proof. The guard raises the bar from "a cookie" to "the password", which is the stated goal, and no further + +## Migration Plan + +No data migration. `aborted` is a new value in a plain `string` status column, so no schema change and no `` bump. + +Ordering matters for the rollout, because the guard is a breaking change to routes the shipped frontend calls: + +1. Attribute, service, middleware, challenge endpoint, registration — inert until a route opts in +2. `abort` route and its event — independently useful, unblocks any already-wedged migration +3. Client `proveMasterPassword()` and the four call sites +4. Apply `#[VaultKeyProofRequired]` to the four routes, plus `VaultKeyProofAttributesTest` + +Steps 3 and 4 must land together, or in that order, or the frontend breaks against its own backend. Migrations already `in_progress` when this deploys are unaffected: the guard applies to starting a rotation and to completing one, and `abort` gives any migration wedged by a pre-fix attempt a way out. + +## Open Questions + +- Should `complete()` keep the `acceptUnrecoverable` acknowledgement now that a key proof is required? It no longer carries the security weight (finding 2), but it is still the mechanism that makes losing a record a decision the owner made rather than a side-effect. Recommendation: keep both; they answer different questions +- The lost-password route (administrator revocation as the only way back to a working vault once this guard blocks a forgotten-password rotation) is out of scope here, and is now partly in place around it: a plain create after revocation already works via #392, and the destruction warning plus the refuse-while-a-usable-emergency-contact-exists enforcement have been folded into #674 (`migrate-emergency-access-on-rotation`). What remains genuinely open is only whether any further UI is needed to walk a forgotten-password user through revoke -> recreate; the destructive mechanics are covered +- ~~`#395` also observes that **any** completed rotation costs the user their emergency access, since `invalidateForGrantorRotation()` fires on `SuiteMigrationCompletedEvent`.~~ **RESOLVED by #674** (`migrate-emergency-access-on-rotation`): rotation now re-envelopes each reachable emergency contact under the new key instead of dropping it, and only a contact whose grantee is unreachable is invalidated — with the owner prompted to re-designate that one specifically. The silent break-glass loss after a routine key change is gone diff --git a/openspec/changes/harden-vault-key-material-guards/plan.json b/openspec/changes/harden-vault-key-material-guards/plan.json new file mode 100644 index 000000000..e776c897e --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/plan.json @@ -0,0 +1,434 @@ +{ + "change": "harden-vault-key-material-guards", + "project": "keepiq", + "repo": "ConductionNL/keepiq", + "base_branch": "development", + "feature_branch": "feature/673/harden-vault-key-material-guards", + "created": "2026-09-10", + "tracking_issue": 673, + "tasks": [ + { + "id": 1, + "num": "1.1", + "title": "Create `lib/Attribute/VaultKeyProofRequired.php`: `#[Attribute(Attribute::TARGET_METHOD)]`, constructor `array $binds = []`, `string $subject = 'active'`; SPDX header per `contribute/HowToApplyALicense.md`", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 2, + "num": "1.2", + "title": "Create `lib/Service/VaultKeyProofService.php` with `issueChallenge(string $userId, string $purpose): array` returning `{nonce, expiresAt}` \u2014 nonce is `base64(ISecureRandom bytes) . '.' . HMAC(instance secret, random|uid|purpose|exp)`; no storage", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 3, + "num": "1.3", + "title": "Implement `VaultKeyProofService::verify(string $nonce, string $signature, string $publicKeyPem, string $userId, string $purpose, array $boundValues): void` \u2014 validate the HMAC, validate the expiry, rebuild the payload as `nonce || sha256(v1) || \u2026 || sha256(vn)` in declared order, verify with `openssl_verify` against the stored public key; throw a typed exception on every failure path with no distinction leaked to the caller", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 4, + "num": "1.4", + "title": "Do NOT use `ICacheFactory` for challenge state (design D5 \u2014 a null cache on a default install would make the guarded flows unusable). Assert this in review", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 5, + "num": "1.5", + "title": "Create `lib/Middleware/VaultKeyProofMiddleware.php` following `JwtAuthMiddleware`: `beforeController` reads the attribute via `new ReflectionMethod($controller, $methodName)`, resolves the subject suite (`'active'` \u2192 the session user's active suite via `EncryptionSuiteService::getActiveSuite`; `'routeParam:'` \u2192 `IRequest::getParam`), collects the bound values via `IRequest::getParam`, reads `X-Keepiq-Key-Proof`, and delegates to the service", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 6, + "num": "1.6", + "title": "Implement `afterException` returning `403` with `['error' => 'key_proof_required', 'message' => \u2026]`; re-throw anything that is not the guard's own exception, as `JwtAuthMiddleware` does", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 7, + "num": "1.7", + "title": "Middleware MUST NOT consult `IUserSession` backends, token scopes or `IPasswordConfirmationBackend` \u2014 no SSO/app-password carve-out (spec: *the guard is not waived*). Add an explanatory comment citing the NC `PasswordConfirmationMiddleware` bypasses this deliberately does not copy", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 8, + "num": "1.8", + "title": "Register in `lib/AppInfo/PlatformIntegrationRegistrar.php` alongside `JwtAuthMiddleware::class`", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 9, + "num": "1.9", + "title": "Run phpcs/phpstan/phpmd \u2014 watch `CouplingBetweenObjects` on the middleware; keep crypto in the service, which is also what makes it unit-testable", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 10, + "num": "2.1", + "title": "Add `proofChallenge(string $id)` to `EncryptionSuiteController` (`#[NoAdminRequired]`), returning `{nonce, expiresAt}` for the calling user and the requested purpose; validate suite ownership", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 11, + "num": "2.2", + "title": "Accept the purpose as a request parameter constrained to a known set (one per guarded operation); reject an unknown purpose", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 12, + "num": "2.3", + "title": "Register `['name' => 'encryptionSuite#proofChallenge', 'url' => '/api/v1/suites/{id}/proof-challenge', 'verb' => 'GET']` in `appinfo/routes.php`, before the SPA catch-all wildcard", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 13, + "num": "2.4", + "title": "The challenge endpoint itself MUST NOT carry `#[VaultKeyProofRequired]` \u2014 assert in the coverage test that it is on the deliberate-exclusion list", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 14, + "num": "3.1", + "title": "Added `MigrationService::abortMigration(string $migrationId): array` \u2014 refuses unless `in_progress` (idempotent no-op otherwise); refuses via `MigrationAbortRefusedException` (mapped to 409) when `MigrationWorkService::countCommitted` finds any record on the new suite, reporting the count and pointing at resume", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 15, + "num": "3.2", + "title": "On success: sets status `aborted`, leaves the old suite `active` and its records untouched, **DELETES** the successor suite via `suiteMapper->delete` (NOT `revokeSuite` \u2014 revoking a user suite cascades the lost-identity share-target sweep + delegation promotion; discovered during implementation, spec/design corrected), clears failure accounting, and releases the write lock (derived from the now-terminal migration)", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 16, + "num": "3.3", + "title": "Created `SuiteMigrationAbortedEvent` + `SuiteMigrationAbortedListener` (registered in `SuiteLifecycleEventRegistrar`) that unlocks the SecretRequests locked at start via `unlockAndUpdateSuite(old, old)`, keeping them on the old suite", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 17, + "num": "3.4", + "title": "Does **not** dispatch `SuiteMigrationCompletedEvent`. `MigrationServiceTest::testAbortDispatchesAbortedEventNotCompleted` asserts the aborted event fires and the completed event does not \u2014 the completed event is the only thing `EmergencyAccessSuiteRotationListener` consumes, so this is the unit-level proof envelopes survive an abort", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 18, + "num": "3.5", + "title": "Added `MigrationController::abort(string $id)` (`#[NoAdminRequired]`) with the existing `requireOwnMigration` check; no `#[VaultKeyProofRequired]` (design D6 \u2014 abort is restorative)", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 19, + "num": "3.6", + "title": "Registered `migration#abort` \u2192 `POST /api/v1/migrations/{id}/abort`", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 20, + "num": "3.7", + "title": "The `compromiseRecovery` refusal already reads \"Resume or **abort** that migration before starting another\" \u2014 that promised route now exists, so the wording is backed rather than broken. Left as-is", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 21, + "num": "3.8", + "title": "Added an \"Abort and keep my old key\" control to `MigrationResumeBanner.vue` (shown while the banner is expanded), plus the `abortMigration` store action and its vitest coverage (success clears the banner; a 409 refusal keeps it and surfaces the message)", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 22, + "num": "4.1", + "title": "Add `proveMasterPassword(encryptedPrivateKey, masterPassword, nonce, boundValues)` to `src/crypto/reauth.js`: decrypt the envelope via `decryptPrivateKey` (`src/crypto/aes.js:79`), re-import the PKCS#8 bytes with `['sign']` usage, sign `nonce || sha256(v1) || \u2026`, return the signature", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 23, + "num": "4.2", + "title": "Discard the derived AES key, the raw PKCS#8 bytes and the signing key immediately after signing; never return, store or cache them (spec: *the signing key does not outlive the proof*). Keep `verifyMasterPassword` as-is for the three existing advisory call sites \u2014 they are out of scope for this change", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 24, + "num": "4.3", + "title": "Confirm the signing key is imported with `['sign']` only and is NOT the session `CryptoKey`; add a unit test asserting the session key (`src/crypto/rsa.js:61-66`) remains non-extractable and `['decrypt']`-only", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 25, + "num": "4.4", + "title": "Add a shared client helper that fetches a challenge, prompts for the master password, produces the proof, and sets the `X-Keepiq-Key-Proof` header \u2014 so the four call sites do not each re-implement it", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 26, + "num": "4.5", + "title": "Wire `src/components/CompromiseRecoveryForm.vue` (recovery start, and the completion call) through the helper", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 27, + "num": "4.6", + "title": "Wire the routine master-password change flow through the helper; verify the old private key is materialised at that point (design \"Risks\" \u2014 if it is not, stop and raise before proceeding)", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 28, + "num": "4.7", + "title": "Wired the emergency-contact delete through the helper: `emergencyAccess.revoke(id, masterPassword)` builds a proof (subject active, bound to the contact id) and `EmergencyAccessView` gained a master-password confirm dialog before it", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 29, + "num": "4.8", + "title": "Completion's proof is now over the OLD key (new middleware subject `migrationOldSuite`), which both the initiate and resume paths already hold the password for \u2014 so resume-completion needs no new prompt. The acknowledgement (\"Finish anyway\") path builds the proof from the retained/re-entered old password, and the form re-shows the password field on a `key_proof_required` refusal", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 30, + "num": "5.1", + "title": "`EncryptionSuiteController::compromiseRecovery` \u2192 `#[VaultKeyProofRequired(binds: ['publicKey', 'encryptedPrivateKey'])]`", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 31, + "num": "5.2", + "title": "`EncryptionSuiteController::updatePrivateKey` \u2192 `#[VaultKeyProofRequired(binds: ['encryptedPrivateKey'], subject: 'routeParam:id')]`", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 32, + "num": "5.3", + "title": "`MigrationController::complete` \u2192 `#[VaultKeyProofRequired]` (defence in depth; the acknowledgement stays \u2014 they answer different questions)", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 33, + "num": "5.4", + "title": "`EmergencyAccessController::destroy` \u2192 `#[VaultKeyProofRequired]`", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 34, + "num": "5.5", + "title": "Create `tests/Unit/Controller/VaultKeyProofAttributesTest.php` in the shape of `RateLimitAttributesTest`: a provider enumerating the four methods with their expected `binds` and `subject`, asserting each by reflection; plus a deliberate-exclusion list (abort, proof-challenge) with the reason recorded per entry", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 35, + "num": "6.1", + "title": "`tests/Unit/Service/VaultKeyProofServiceTest.php`: valid proof passes; wrong key fails; altered bound value fails; altered nonce fails; expired nonce fails (injected `ITimeFactory`); wrong purpose fails; proof for one parameter set rejected against another", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 36, + "num": "6.2", + "title": "`tests/Unit/Middleware/VaultKeyProofMiddlewareTest.php`: attribute absent \u2192 pass-through; attribute present without header \u2192 403 `key_proof_required`; `subject: 'active'` and `'routeParam:id'` both resolve; `afterException` re-throws foreign exceptions", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 37, + "num": "6.3", + "title": "`VaultKeyProofCrossImplTest` verifies a browser-scheme (WebCrypto RSASSA-PKCS1-v1_5 SHA-256) signature with PHP `openssl_verify` over `VaultKeyProofService::signedMessage`; a tampered bound value breaks it. Fixture at `tests/fixtures/vault-key-proof.json`, regenerated by `generate-vault-key-proof-fixture.mjs`", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 38, + "num": "6.4", + "title": "`MigrationServiceTest` covers abort: restores/deletes-successor on an untouched migration; refused-after-commit with the count; idempotent by status; the aborted-not-completed event (the unit-level proof emergency envelopes survive)", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 39, + "num": "6.5", + "title": "PARTIAL \u2014 the guard is enforced by middleware, not the controllers, so a real without-proof 403 needs the request pipeline (out of scope for isolated PHPUnit, same rationale as RateLimitAttributesTest). `VaultKeyProofMiddlewareTest` covers dispatch/refusal and `VaultKeyProofAttributesTest` pins coverage; a full pipeline assertion is a Newman/e2e follow-up", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 40, + "num": "6.6", + "title": "PARTIAL \u2014 finding 2 is closed structurally: the guard sits at rotation ENTRY (`compromiseRecovery`), which the coverage + middleware tests enforce, so garbage-commit can never start. A dynamic end-to-end regression belongs with the \u00a77.6 live reproduction", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 41, + "num": "6.7", + "title": "`tests/vitest/proveMasterPassword.spec.js`: signature verifies against the suite public key; wrong password throws before signing; a changed bound value fails; the session key is pinned non-extractable / decrypt-only. The 403 re-enter path is wired in `CompromiseRecoveryForm` (\u00a74.8)", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 42, + "num": "7.1", + "title": "Run the hydra gates locally: route-auth (two new routes), no-admin-idor, gate-16 spec-coverage, gate-113 exclusion-evidence (every `@e2e exclude` in this change carries a reason). Note the known pre-existing `no-admin-idor` debt on `development` is not introduced here", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 43, + "num": "7.2", + "title": "Confirmed gate-110 does not apply: no `lib/Migration/` files added and `appinfo/info.xml` `` unchanged (the abort `aborted` status is a plain string-column value)", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 44, + "num": "7.3", + "title": "Documented in `docs/ARCHITECTURE.md` \u00a74.2: the guarded-route table, the attribute contract, the load-bearing design points, and the rule that a new destructive route MUST be added to `VaultKeyProofAttributesTest`", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 45, + "num": "7.4", + "title": "Every branch commit carries `Assisted-by: ClaudeCode:claude-opus-5` and no `Signed-off-by` (keepiq does not require DCO \u2014 that is Nextcloud's policy, for nextcloud/* repos)", + "status": "done", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 46, + "num": "7.5", + "title": "The PR description discloses AI tool use, in the contributor's own words, and links issue #395", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 47, + "num": "7.6", + "title": "Before opening: re-read #395's \"Verification status\" \u2014 the chain was never executed end to end. Reproduce the lockout on a throwaway account against pre-fix code, then confirm the same steps are refused post-fix. This is the issue's own first task and it is still outstanding", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + } + ] +} diff --git a/openspec/changes/harden-vault-key-material-guards/proposal.md b/openspec/changes/harden-vault-key-material-guards/proposal.md new file mode 100644 index 000000000..aa325e08d --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/proposal.md @@ -0,0 +1,61 @@ +## Why + +Issue #395 (`ConductionNL/keepiq`, 2026-08-21) reports that an attacker holding **only an authenticated Nextcloud session** for a Keepiq user can permanently destroy that user's access to their entire vault — including their pre-arranged emergency-access recovery — without learning a single secret. + +The asymmetry is the point. Keepiq is zero-knowledge: a session alone does not let anyone read the vault, because decryption needs the master password, which the server never holds. A stolen session is therefore normally *not* game-over. These paths turn it into one, for destruction rather than disclosure. + +Stated as the invariant that is currently violated: + +> Reading the vault requires the master password. Destroying it requires a cookie. + +Re-verified on `development` @ `c1cac29c`, four independent paths reach permanent loss from a session alone: + +| Path | Effect | Reversible today | Gate today | +|---|---|---|---| +| `PUT /api/v1/suites/{id}/private-key` | overwrites the private-key envelope in place | only via emergency access | ownership | +| `POST /api/v1/suites/compromise-recovery` | mints a successor suite under attacker-supplied key material, write-locks the vault | **no — no abort route exists** | ownership | +| `POST /api/v1/migrations/{id}/secrets/{secretId}` | writes client-supplied ciphertext verbatim over the original | no | ownership | +| `POST /api/v1/migrations/{id}/complete` | marks the old suite `compromised`, invalidates emergency access | no | acknowledgement count (see below) | +| `DELETE /api/v1/emergency-access/contacts/{id}` | deletes the recovery envelope — the only survivor of row 1 | no | ownership | + +Three findings beyond the filed issue, established while tracing it: + +1. **`updatePrivateKey` is a one-request version of the same lockout.** Its only gate is `validateOwnership()`. Posting a garbage envelope means the master password no longer decrypts anything, and the private key existed *only* as that envelope. Fewer steps than the filed chain, no acknowledgement, no audit trail, and no write-lock guard — so it works mid-migration too. +2. **The acknowledgement gate in `complete()` can be bypassed entirely.** The filed chain reports per-record failures, which forces the `acceptUnrecoverable` handshake. An attacker need not: `reEncryptSecret()` accepts client-supplied ciphertext and `commitSecret()` writes it verbatim, and the round-trip verification the spec names is performed in the *browser* — the server structurally cannot repeat it under ADR-003. Committing garbage for every record yields zero failures, so completion succeeds with no acknowledgement at all. **Hardening `complete()` therefore does not close the hole; the gate has to be at the entry to rotation.** +3. **The safety net is removable by the same authority.** `EmergencyAccessController::destroy()` is session-only, so the attack sequence is *delete the emergency contacts, then lock out*. + +Two facts make this cheap to fix correctly rather than expensively: + +- **The spec already assumes the gate exists.** `encryption-suites` -> *Master Password Change — Compromise Recovery* reads "AND provides their old master password and a new master password". The old password *is* collected; it is consumed entirely client-side, so the server never observes any consequence of it. This change does not introduce new policy — it makes the server able to verify what the spec already claims. +- **The client-side half is already written.** `src/crypto/reauth.js` implements master-password re-authentication and documents its own limitation: *"a 're-auth' gate is a CLIENT-SIDE proof of knowledge... The control is advisory against a tampered client."* It is already used by `AccountDeletionDialog.vue:199`, `CxpTransferDialog.vue:365` and `ExportDialog.vue:349`. The upgrade is a return type: a boolean the client consumes becomes a signature the **server** verifies. + +`#392` (`d475d00d`, *refuse a plain create when the owner already has an active suite*) closed one milder instance of the same theme and does not address any path above. + +## What Changes + +- Introduce a reusable, attribute-driven guard — `#[VaultKeyProofRequired]` plus `VaultKeyProofMiddleware` — that refuses a request unless it carries a signature, made with the private key of the owner's EncryptionSuite, over a server-issued challenge bound to the operation's own parameters. Because the private key is only obtainable by decrypting its envelope with the master password, this is a server-verifiable proof of the master password +- Add a challenge endpoint (`GET /api/v1/suites/{id}/proof-challenge`) issuing a stateless, expiring, HMAC-authenticated nonce +- Apply the guard to `compromiseRecovery`, `updatePrivateKey`, `complete` and the emergency-contact `destroy` route +- Add the **abort** route that `compromiseRecovery()`'s own error message already promises ("Resume or **abort** that migration before starting another") but which does not exist in `appinfo/routes.php` or `MigrationController`. Abort is permitted only while no record has been committed, releases the write lock, revokes the unused successor suite, and leaves the old suite `active` +- Add an attribute-coverage test in the shape of the existing `RateLimitAttributesTest`, asserting every route on the destructive list carries the guard — so a future destructive route that forgets it fails the build rather than failing open +- Extend `src/crypto/reauth.js` with `proveMasterPassword()`, returning a signature instead of a boolean, and wire the four guarded flows to fetch a challenge and send the proof header + +Explicitly **not** in scope: retrofitting the three existing advisory `verifyMasterPassword()` call sites (export, CXP transfer, account deletion) onto the middleware. That is a clean follow-up once the guard exists, and folding it in here would roughly double the diff for an unrelated concern (see AGENTS.md on PR size). + +## Capabilities + +### New Capabilities +- `vault-key-proof`: A server-verified proof of master-password knowledge, expressed as a signature over a server-issued challenge made with the owner's suite private key, applied declaratively to controller methods via a PHP attribute and enforced by app middleware. Covers challenge issuance and expiry, the binding of a proof to the parameters of the operation it authorises, the signature-over-decryption requirement, and the fail-closed coverage guarantee + +### Modified Capabilities +- `encryption-suites`: compromise recovery and private-key replacement require a verified key proof; a migration gains an abort terminal state and the route that reaches it; the "always has a way to terminate" requirement gains the abort escape it currently lacks +- `emergency-access`: deleting an emergency contact requires a verified key proof, since it destroys the only recovery path that survives a private-key overwrite + +## Impact + +- **Database**: none. `SuiteMigration::$status` is a plain `string` column (`lib/Db/SuiteMigration.php:115`), so the new `aborted` terminal value needs no schema change — and therefore no migration and no `` bump for gate-110. The stateless nonce design adds no table +- **Backend**: new `lib/Attribute/VaultKeyProofRequired.php`, `lib/Middleware/VaultKeyProofMiddleware.php`, `lib/Service/VaultKeyProofService.php`; new `abort` action on `MigrationController` and `SuiteMigrationAbortedEvent`; challenge endpoint on `EncryptionSuiteController`; middleware registered in `PlatformIntegrationRegistrar` alongside the existing `JwtAuthMiddleware` +- **Frontend**: `src/crypto/reauth.js` gains `proveMasterPassword()`; `CompromiseRecoveryForm.vue`, the routine password-change flow, the emergency-contact delete action and the migration-completion call each fetch a challenge and send the proof header; a new abort control on `MigrationResumeBanner.vue` +- **API**: two new endpoints (`proof-challenge`, `abort`); four existing routes begin requiring the `X-Keepiq-Key-Proof` header and answer `403 {"error": "key_proof_required"}` without it. Breaking for any client of those four routes — acceptable and deliberate while the app carries its pre-production disclaimers +- **Security**: this is the whole point of the change. The guard resists a stolen session, a leaked app password, and XSS in an *already-unlocked* tab — the last because the session `CryptoKey` is imported non-extractable and `['decrypt']`-only (`src/crypto/rsa.js:61-66`), so it cannot produce a signature. See `design.md` D2, which is load-bearing and must not be "simplified" to a decrypt-based challenge +- **Cross-app**: none. Every guarded route is session-authenticated (`#[NoAdminRequired]`, owner derived from `IUserSession`). Application-owned suites hold no server-side envelope at all (`EncryptionSuiteProvisioningService` stores `encryptedPrivateKey: ''`) and authenticate via `JwtAuthMiddleware` on `ApplicationApiController` routes, which this change does not touch. OpenConnector is unaffected diff --git a/openspec/changes/harden-vault-key-material-guards/specs/emergency-access/spec.md b/openspec/changes/harden-vault-key-material-guards/specs/emergency-access/spec.md new file mode 100644 index 000000000..24566fe9e --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/specs/emergency-access/spec.md @@ -0,0 +1,23 @@ +## MODIFIED Requirements + +### Requirement: Revoke Emergency Contact +The grantor MUST be able to revoke an emergency contact at any time. Revocation MUST delete the recovery envelope and cancel any pending request, and a revoked contact MUST NOT be able to break glass until re-designated (which rebuilds a fresh envelope). + +Revocation MUST require a verified key proof (see the `vault-key-proof` capability). The recovery envelope is the only copy of the grantor's private key that survives a replacement of the stored envelope, which makes it the last recovery path out of an account lockout. An attacker holding the grantor's session would otherwise be able to delete the safety net first and destroy the vault second, using the same authority for both. + +The requirement is on the grantor-initiated revocation of a designated contact. Envelope clearing that follows from suite revocation or rotation is a consequence of those operations, is governed by *Envelope Invalidation on Key Change*, and is not separately gated here. + +#### Scenario: Revoked contact cannot break glass +@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). This waiver covers only that server-side state machine, which is not DOM-observable. The DOM flow itself is not excluded, it is uncovered: src/views/EmergencyAccessView.vue is routed at /emergency-access, has an "Emergency access" menu entry and carries data-testid hooks (emergency-access-view, emergency-access-designate, emergency-grantee-input, emergency-wait-select, emergency-master-input), and the E2E Tests (Playwright) job provisions its own throwaway Nextcloud seeded by tests/e2e/ci-seed.sh. A Playwright spec for it is open work and nothing here claims one exists. +- **GIVEN** A has designated B as an emergency contact +- **WHEN** A revokes B +- **THEN** the recovery envelope MUST be deleted and any pending request cancelled +- **AND** B MUST be unable to initiate or complete a break-glass request until re-designated + +#### Scenario: Revocation without a key proof is refused +@e2e exclude Middleware enforcement on a session-authenticated route; not DOM-observable. Covered by PHPUnit on the middleware and the attribute-coverage test. +- **GIVEN** A has designated B as an emergency contact +- **AND** an authenticated session for A +- **WHEN** revocation is requested without a verified key proof +- **THEN** the system MUST refuse with `403` and `error: key_proof_required` +- **AND** the recovery envelope MUST be unchanged and still usable diff --git a/openspec/changes/harden-vault-key-material-guards/specs/encryption-suites/spec.md b/openspec/changes/harden-vault-key-material-guards/specs/encryption-suites/spec.md new file mode 100644 index 000000000..8f59694cf --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/specs/encryption-suites/spec.md @@ -0,0 +1,142 @@ +## MODIFIED Requirements + +### Requirement: Master Password Change — Routine +The system MUST allow a user to change their master password for routine hygiene reasons. In this case, the RSA key pair MUST remain unchanged — only the AES wrapping of the private key changes. + +Replacing the stored private-key envelope MUST require a verified key proof (see the `vault-key-proof` capability). The envelope is the only copy of the private key, so a request that replaces it with material the owner cannot open destroys the vault in a single call; an ownership check alone is therefore insufficient authority. + +The proof MUST be bound to the submitted envelope, and MUST be verified against the public key of the suite named in the route. + +The flow already holds the current master password in order to derive the old AES key, so the proof imposes no additional prompt: the raw private key is materialised at exactly the moment the signature must be produced. An implementation that re-wraps the envelope without materialising the old private key would be unable to produce the proof and MUST NOT be adopted. + +#### Scenario: Routine password change +@e2e exclude The password-change form is rendered inside the user-settings dialog; verifying that AES key re-wrapping succeeded requires reading back the encrypted private-key blob — a crypto-API assertion, not DOM-observable. The form's UI surface is captured in user-settings::user-opens-settings. +- GIVEN a user provides their current master password and a new master password +- AND the new master password meets the configured strength floor +- WHEN the change is submitted +- THEN the system MUST decrypt the private key using the current AES-derived key +- AND re-encrypt it using the new AES-derived key +- AND store the updated blob +- AND no secrets are affected + +#### Scenario: Envelope replacement without a key proof is refused +@e2e exclude Middleware enforcement on a session-authenticated route; not DOM-observable. Covered by PHPUnit on the middleware and the attribute-coverage test. +- **GIVEN** an authenticated session for the suite owner +- **WHEN** a replacement private-key envelope is submitted without a verified key proof +- **THEN** the system MUST refuse with `403` and `error: key_proof_required` +- **AND** the stored envelope MUST be unchanged + +### Requirement: Master Password Change — Compromise Recovery +When a user indicates their master password has been compromised, the system MUST initiate a full key rotation: a new RSA key pair is generated, all secrets are re-encrypted, and the old EncryptionSuite is flagged as compromised. + +Initiating compromise recovery MUST require a verified key proof over the **old** suite's private key (see the `vault-key-proof` capability). The proof MUST be bound to the submitted successor public key and successor private-key envelope. + +Without it, the operation proves nothing about the suite it replaces: any holder of the owner's session can submit their own key pair, become the write target by suite resolution, and reach a terminal state that locks the old suite. Every downstream variant of that attack — reporting records unrecoverable, or committing ciphertext the owner cannot open — is reachable only through this entry point, so this is where the gate belongs. Gating completion alone is insufficient, because a caller who commits ciphertext for every record produces zero failures and needs no acknowledgement. + +Requiring the proof does not obstruct legitimate recovery: rotation exists for a key that may be **exposed**, not for a password that was **forgotten**, so a user rotating still knows their master password. A user who has genuinely lost it MUST be routed to administrator revocation instead, which produces an empty vault and is not a recovery. + +#### Scenario: Compromise recovery initiated +@e2e exclude Verifying RSA key pair generation, SuiteMigration record creation, and write-lock application requires inspecting server-side crypto state — not DOM-observable. The recovery UI form renders in the user-settings dialog and its presence is captured in user-settings::user-opens-settings. +- GIVEN a user selects "my master password was leaked" as the reason for changing their password +- AND provides their old master password and a new master password +- WHEN the change is submitted +- THEN the system MUST generate a new RSA key pair and EncryptionSuite +- AND create a SuiteMigration record with status `in_progress` +- AND apply a write lock to the account (no create/update operations on secrets) +- AND lock all pending SecretRequests (see secret-requests spec) +- AND begin migrating all secrets from the old suite to the new suite + +#### Scenario: Recovery without proof of the old key is refused +@e2e exclude Middleware enforcement on a session-authenticated route; not DOM-observable. Covered by PHPUnit on the middleware and the attribute-coverage test. +- **GIVEN** an authenticated session for a user with an active EncryptionSuite +- **WHEN** compromise recovery is requested with key material not accompanied by a verified proof over the existing suite's private key +- **THEN** the system MUST refuse with `403` and `error: key_proof_required` +- **AND** MUST NOT create a successor suite, a migration record, or a write lock + +### Requirement: A Migration Always Has A Way To Terminate + +A migration MUST always have a way to terminate. Completion is therefore gated on rows nobody has attempted, NOT on every row still bound to `old_suite_id`. The two are different situations and conflating them makes the write lock inescapable: a record that can never be re-encrypted would hold the migration open forever, leaving the owner permanently unable to write to their own vault. + +Termination MUST be reachable in both directions. Completion carries the migration forward to the new suite; **abort** returns it to the old suite. A migration that can only be completed is not terminable in the sense this requirement intends, because the only available exit is the destructive one — which is what made a hostile or abandoned rotation unrecoverable. + +A row is **unaccounted for** when it is still bound to `old_suite_id` and its owning secret carries no `migration_error`. The system MUST refuse to terminate a migration while any unaccounted-for row exists, because terminating locks the old suite and would take every un-reached row down with it. The refusal MUST name the remaining count and point at resuming, and MUST point at aborting when aborting is still available. + +A row that was attempted and recorded a failure MUST NOT block termination. Terminating with such rows present MUST require an explicit acknowledgement from the client stating how many records it accepts losing, and the count MUST match what the server observes; an absent or mismatched acknowledgement MUST be refused. This makes locking a secret out of the vault a decision the owner made, never a side-effect of a client calling completion — a run in which every record failed would otherwise silently lock an owner out of everything. + +Completion MUST additionally require a verified key proof (see the `vault-key-proof` capability). The acknowledgement establishes that the owner accepts the loss; the proof establishes that the caller is the owner. These answer different questions and the system MUST require both. + +Only a failure to decrypt the EXISTING ciphertext with the old key may be recorded as a per-record failure. A re-encryption that does not survive its round-trip check MUST NOT be recorded, because the original decrypted successfully and is therefore readable: the fault lies in the new key material, it will recur on every record, and the run MUST stop instead. It follows that finalisation can only ever remove access from rows that were already unreadable under the old key. + +#### Scenario: Unattempted rows refuse termination and point at resuming + +@e2e exclude Server-side query and status transition; covered by PHPUnit on the completion path. +- **GIVEN** a migration whose client stopped before processing every record, leaving rows with no `migration_error` +- **WHEN** completion is requested +- **THEN** the server MUST refuse, MUST leave the old suite `active`, and MUST keep the migration `in_progress` +- **AND** the refusal MUST report how many records remain and state that the migration can be resumed + +#### Scenario: An unrecoverable record does not trap the vault + +@e2e exclude Terminal status transition and suite locking are server-side; covered by PHPUnit on the completion path. +- **GIVEN** a migration in which every remaining row on `old_suite_id` has a recorded `migration_error` +- **WHEN** completion is requested WITHOUT an acknowledgement +- **THEN** the server MUST refuse and MUST state how many records would lose access +- **WHEN** completion is requested WITH an acknowledgement matching that count and a verified key proof +- **THEN** the migration MUST terminate as `completed_with_errors`, the old suite MUST be locked, and the write lock MUST be released +- **AND** the response MUST identify the secrets that lost access + +#### Scenario: A round-trip failure halts rather than sacrificing the record + +@e2e exclude Injected at the crypto layer; no DOM path induces it. Covered by unit tests of the migration pipeline. +- **GIVEN** a record whose existing ciphertext decrypts correctly but whose re-encryption does not survive the round-trip check +- **WHEN** the migration processes that record +- **THEN** the failure MUST NOT be recorded as a per-record migration failure +- **AND** the run MUST stop so the new key material can be investigated +- **AND** records already committed MUST remain valid, each having been verified before its own commit + +## ADDED Requirements + +### Requirement: A Migration Can Be Aborted Before Any Record Moves + +The system MUST provide a route to abort a migration in progress, and `compromise-recovery`'s refusal message MUST NOT name a remedy that does not exist. + +Abort MUST be permitted only while no record has been committed to the new suite. Once any record has moved, the two available outcomes both lose data — revoking the successor strands what has moved, keeping it active strands what has not — so the system MUST refuse to abort, MUST name the number of records already committed, and MUST point at resuming instead. + +Restricting abort this way is sufficient for the case it exists to remedy: producing valid re-encrypted ciphertext requires the plaintext, and therefore the master password, so a caller who cannot prove possession of the old key can never have committed a record. + +On abort the system MUST: + +- set the migration to the terminal status `aborted` +- leave the old EncryptionSuite `active`, and leave every record bound to it untouched +- discard the successor suite by **deleting** it — created moments ago, it holds no ciphertext and has no shares or emergency contacts, so it is removed outright. It MUST NOT be revoked through the ordinary suite-revocation path: that path treats a revoked *user* suite as a lost identity and cascades a share-target sweep and delegation promotion, which would destroy the owner's incoming shares over a migration the abort exists to undo +- release the write lock and unlock the SecretRequests locked when the migration started +- clear the migration's failure accounting, so a later migration does not inherit a stale acknowledgement threshold + +Abort MUST NOT dispatch the migration-completed event. That event is what invalidates the owner's emergency-access recovery envelopes, and abort exists precisely to avoid that loss. + +Abort MUST NOT require a key proof. It is restorative — it returns the vault to a suite that is still `active` and readable — and requiring proof of a key would leave a wedged vault wedged, including one wedged by a rotation the owner never authorised. A caller who aborts another user's legitimate rotation causes a nuisance the owner can simply repeat, which is not comparable to permanent loss. + +#### Scenario: Aborting an untouched migration restores the old suite + +@e2e exclude Terminal status transition, suite status and write-lock release are server-side. Covered by PHPUnit on the abort path. +- **GIVEN** a migration `in_progress` with no record committed to the new suite +- **WHEN** abort is requested by the owner +- **THEN** the migration MUST become `aborted` +- **AND** the old suite MUST remain `active` with every record still bound to it +- **AND** the successor suite MUST be deleted (not revoked, which would cascade the user-suite revocation side effects) +- **AND** the write lock MUST be released and locked SecretRequests MUST be unlocked + +#### Scenario: Aborting after records have moved is refused + +@e2e exclude Server-side query and status transition. Covered by PHPUnit on the abort path. +- **GIVEN** a migration in which at least one record has been committed to the new suite +- **WHEN** abort is requested +- **THEN** the system MUST refuse, MUST keep the migration `in_progress` +- **AND** MUST report how many records have already been committed and state that the migration can be resumed + +#### Scenario: Abort does not destroy emergency access + +@e2e exclude Event dispatch and listener side effects are server-side. Covered by PHPUnit asserting the completed event is not dispatched and envelopes are unchanged. +- **GIVEN** an owner with a designated emergency contact and a migration `in_progress` with no record committed +- **WHEN** the migration is aborted +- **THEN** the emergency-access recovery envelopes MUST be unchanged and still usable diff --git a/openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md b/openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md new file mode 100644 index 000000000..b66551c9a --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md @@ -0,0 +1,121 @@ +## ADDED Requirements + +### Requirement: Irreversible Operations Require A Verified Key Proof + +The system MUST refuse any operation that can render vault contents or key material permanently unreadable unless the request carries a **key proof**: a signature, made with the private key of the owner's EncryptionSuite, over a challenge the server issued. + +The server MUST verify the signature against the public key it already stores for the subject suite. Because a suite's private key exists only inside an AES envelope keyed by PBKDF2-SHA256 over the master password, a verified proof establishes that the caller knows the master password. A Nextcloud session alone MUST NOT be sufficient authority for any such operation. + +The guard MUST be declared on the controller method via a `#[VaultKeyProofRequired]` attribute and enforced by middleware, so that the requirement is legible at the route and cannot be satisfied by controller code that forgets to call it. + +A request missing or failing the proof MUST be refused with `403` and a machine-readable `error` of `key_proof_required`, so a client can distinguish "obtain a challenge and retry" from a terminal failure. + +The guard MUST NOT consult the authentication backend, and MUST NOT be waived for SSO sessions, app passwords, or any token scope. Its authority derives from key material, not from how the session was established. + +#### Scenario: A session without a proof is refused + +@e2e exclude Middleware dispatch and signature verification are server-side; a DOM flow cannot present a request with the proof header withheld. Covered by PHPUnit on the middleware and service. +- **GIVEN** an authenticated session for a user who owns an active EncryptionSuite +- **WHEN** a guarded operation is requested without a key proof +- **THEN** the system MUST refuse with `403` and `error: key_proof_required` +- **AND** MUST NOT perform any part of the operation + +#### Scenario: A valid proof admits the operation + +@e2e exclude Requires signing with raw private-key bytes held only transiently in JS memory; not observable or triggerable via Playwright DOM. Covered by PHPUnit plus a cross-implementation round-trip test. +- **GIVEN** a challenge issued for the caller and the operation +- **AND** a signature over that challenge made with the subject suite's private key +- **WHEN** the guarded operation is requested carrying that proof +- **THEN** the system MUST verify the signature against the stored public key and proceed + +#### Scenario: The guard is not waived for SSO or app-password sessions + +@e2e exclude Requires provisioning an SSO or app-password session against a live instance. Covered by PHPUnit asserting the middleware reads no token scope and no user backend. +- **GIVEN** a session established by SSO, or authenticated with an app password +- **WHEN** a guarded operation is requested without a key proof +- **THEN** the system MUST refuse exactly as for an ordinary session + +### Requirement: The Proof Is A Signature, Never A Decryption + +The proof MUST be a signature produced with the subject suite's private key. The system MUST NOT accept, as proof, the decryption of a server-issued ciphertext. + +The browser holds the unlocked session key as a WebCrypto `CryptoKey` imported non-extractable with `['decrypt']` usage only. A decryption challenge would therefore be satisfiable by any unlocked tab, and so by script injected into one, which would defeat the guard for the attacker it most needs to stop. Signing requires re-importing the private key with `['sign']` usage from raw PKCS#8 bytes, which are obtainable only by decrypting the envelope with a freshly entered master password. + +The client MUST derive the signing key at the moment the master password is entered and MUST discard it immediately after signing. It MUST NOT retain a signing-capable key for the duration of the session, because doing so would grant injected script the capability this requirement exists to withhold. + +#### Scenario: An unlocked session cannot produce a proof by itself + +@e2e exclude The in-memory CryptoKey and its usage flags cannot be inspected via Playwright DOM. Covered by unit tests of the client crypto module asserting the session key is imported with `['decrypt']` only. +- **GIVEN** a vault unlocked in the browser, with the session `CryptoKey` in memory +- **WHEN** a key proof is required and the master password has not been re-entered +- **THEN** the client MUST be unable to produce a signature from the session key +- **AND** MUST prompt for the master password + +#### Scenario: The signing key does not outlive the proof + +@e2e exclude JavaScript memory lifetime is not observable via Playwright DOM. Covered by unit tests asserting the derived key is not returned, stored, or retained after signing. +- **GIVEN** the user has entered their master password to authorise a guarded operation +- **WHEN** the signature has been produced +- **THEN** the client MUST discard the derived AES key and the signing key +- **AND** MUST NOT place either in `localStorage`, `sessionStorage`, or a store that outlives the operation + +### Requirement: A Proof Is Bound To The Operation It Authorises + +A key proof MUST commit to the parameters of the operation it authorises, so that a captured proof cannot be replayed onto a different operation. + +The `#[VaultKeyProofRequired]` attribute MUST declare which request parameters the proof binds to, and the signed payload MUST be the challenge followed by the digest of each declared parameter, hashed individually in the declared order. The attribute MUST also declare which suite's public key verifies the proof: by default the caller's active suite, or a suite named by a route parameter. + +Binding MUST NOT be expressed as a digest over the whole request body. The framework decodes a JSON body and discards the raw bytes, so a whole-body digest would require re-reading the input stream outside the request abstraction, and would additionally require client and server to agree on a canonical serialisation. + +A challenge MUST additionally be bound to a single purpose, so that a proof obtained for one guarded operation cannot be presented to another. + +#### Scenario: A proof does not transfer to a different operation + +@e2e exclude Server-side signature verification against a bound payload; not DOM-observable. Covered by PHPUnit on the middleware. +- **GIVEN** a valid proof issued and signed for one guarded operation +- **WHEN** it is presented to a different guarded operation +- **THEN** the system MUST refuse it + +#### Scenario: A proof does not transfer to different parameters + +@e2e exclude As above. Covered by PHPUnit on the middleware. +- **GIVEN** a valid proof bound to a set of request parameters +- **WHEN** the same proof is presented with any bound parameter altered +- **THEN** the system MUST refuse it + +### Requirement: Challenges Are Stateless And Expiring + +The system MUST issue key-proof challenges through an endpoint that requires only an authenticated session, and MUST NOT require server-side storage to verify them. + +A challenge MUST carry a random component and MUST be authenticated with the instance secret over that component, the caller, the purpose, and an expiry. The system MUST reject an expired or unauthenticated challenge. + +The system MUST NOT depend on a distributed cache to hold challenge state. Nextcloud returns a null cache when none is configured, and a challenge store that silently forgets would make the guarded flows unusable on a default installation. + +Single-use enforcement is NOT required, because a proof is bound to its operation's parameters and therefore replays only ever re-authorise the byte-identical operation. + +#### Scenario: An expired challenge is refused + +@e2e exclude Time-dependent server-side verification; not DOM-observable. Covered by PHPUnit with an injected time factory. +- **GIVEN** a challenge whose expiry has passed +- **WHEN** a proof over it is presented +- **THEN** the system MUST refuse the request with `error: key_proof_required` + +#### Scenario: A forged challenge is refused + +@e2e exclude Server-side HMAC verification; not DOM-observable. Covered by PHPUnit. +- **GIVEN** a challenge not issued by this instance, or altered after issue +- **WHEN** a proof over it is presented +- **THEN** the system MUST refuse the request + +### Requirement: Guard Coverage Is Enforced By Test + +Because a declarative guard fails open when it is omitted, the system MUST carry a test that enumerates every operation required to be guarded and asserts, by reflection, that each carries `#[VaultKeyProofRequired]` with the expected binding and subject. + +Adding a route that can render vault contents or key material permanently unreadable without adding it to that enumeration MUST be treated as a defect in this requirement, not as an accepted gap. + +#### Scenario: A guarded route that loses its attribute fails the build + +@e2e exclude Attribute reflection over controller methods; the middleware itself needs a running instance to produce a 403, which is out of scope for an isolated PHPUnit run — the same rationale documented for `RateLimitAttributesTest`. +- **GIVEN** the enumeration of operations required to carry a key proof +- **WHEN** any enumerated method does not carry `#[VaultKeyProofRequired]`, or carries it with an unexpected binding or subject +- **THEN** the test suite MUST fail diff --git a/openspec/changes/harden-vault-key-material-guards/tasks.md b/openspec/changes/harden-vault-key-material-guards/tasks.md new file mode 100644 index 000000000..cb0b6b6ac --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/tasks.md @@ -0,0 +1,75 @@ +## 0. Read First — Ordering Constraint + +The guard is a breaking change to four routes the shipped frontend already calls. Sections 1–3 are inert (nothing opts in yet). **Section 5 must not land before section 4**, or the frontend breaks against its own backend. + +No database migration: `SuiteMigration::$status` is a plain `string` column (`lib/Db/SuiteMigration.php:115`), so the new `aborted` value needs no schema change and no `` bump — gate-110 does not apply to this change. If that assumption changes, revisit before merging. + +Section 3 (abort) is independently useful and can be split into its own PR if the whole change grows too large for one review — it has no dependency on sections 1, 2, 4 or 5. + +## 1. Backend — The Guard Primitive + +- [x] 1.1 Create `lib/Attribute/VaultKeyProofRequired.php`: `#[Attribute(Attribute::TARGET_METHOD)]`, constructor `array $binds = []`, `string $subject = 'active'`; SPDX header per `contribute/HowToApplyALicense.md` +- [x] 1.2 Create `lib/Service/VaultKeyProofService.php` with `issueChallenge(string $userId, string $purpose): array` returning `{nonce, expiresAt}` — nonce is `base64(ISecureRandom bytes) . '.' . HMAC(instance secret, random|uid|purpose|exp)`; no storage +- [x] 1.3 Implement `VaultKeyProofService::verify(string $nonce, string $signature, string $publicKeyPem, string $userId, string $purpose, array $boundValues): void` — validate the HMAC, validate the expiry, rebuild the payload as `nonce || sha256(v1) || … || sha256(vn)` in declared order, verify with `openssl_verify` against the stored public key; throw a typed exception on every failure path with no distinction leaked to the caller +- [x] 1.4 Do NOT use `ICacheFactory` for challenge state (design D5 — a null cache on a default install would make the guarded flows unusable). Assert this in review +- [x] 1.5 Create `lib/Middleware/VaultKeyProofMiddleware.php` following `JwtAuthMiddleware`: `beforeController` reads the attribute via `new ReflectionMethod($controller, $methodName)`, resolves the subject suite (`'active'` → the session user's active suite via `EncryptionSuiteService::getActiveSuite`; `'routeParam:'` → `IRequest::getParam`), collects the bound values via `IRequest::getParam`, reads `X-Keepiq-Key-Proof`, and delegates to the service +- [x] 1.6 Implement `afterException` returning `403` with `['error' => 'key_proof_required', 'message' => …]`; re-throw anything that is not the guard's own exception, as `JwtAuthMiddleware` does +- [x] 1.7 Middleware MUST NOT consult `IUserSession` backends, token scopes or `IPasswordConfirmationBackend` — no SSO/app-password carve-out (spec: *the guard is not waived*). Add an explanatory comment citing the NC `PasswordConfirmationMiddleware` bypasses this deliberately does not copy +- [x] 1.8 Register in `lib/AppInfo/PlatformIntegrationRegistrar.php` alongside `JwtAuthMiddleware::class` +- [x] 1.9 Run phpcs/phpstan/phpmd — watch `CouplingBetweenObjects` on the middleware; keep crypto in the service, which is also what makes it unit-testable + +## 2. Backend — Challenge Endpoint + +- [x] 2.1 Add `proofChallenge(string $id)` to `EncryptionSuiteController` (`#[NoAdminRequired]`), returning `{nonce, expiresAt}` for the calling user and the requested purpose; validate suite ownership +- [x] 2.2 Accept the purpose as a request parameter constrained to a known set (one per guarded operation); reject an unknown purpose +- [x] 2.3 Register `['name' => 'encryptionSuite#proofChallenge', 'url' => '/api/v1/suites/{id}/proof-challenge', 'verb' => 'GET']` in `appinfo/routes.php`, before the SPA catch-all wildcard +- [x] 2.4 The challenge endpoint itself MUST NOT carry `#[VaultKeyProofRequired]` — assert in the coverage test that it is on the deliberate-exclusion list + +## 3. Backend — Abort (independently mergeable) — IMPLEMENTED + +- [x] 3.1 Added `MigrationService::abortMigration(string $migrationId): array` — refuses unless `in_progress` (idempotent no-op otherwise); refuses via `MigrationAbortRefusedException` (mapped to 409) when `MigrationWorkService::countCommitted` finds any record on the new suite, reporting the count and pointing at resume +- [x] 3.2 On success: sets status `aborted`, leaves the old suite `active` and its records untouched, **DELETES** the successor suite via `suiteMapper->delete` (NOT `revokeSuite` — revoking a user suite cascades the lost-identity share-target sweep + delegation promotion; discovered during implementation, spec/design corrected), clears failure accounting, and releases the write lock (derived from the now-terminal migration) +- [x] 3.3 Created `SuiteMigrationAbortedEvent` + `SuiteMigrationAbortedListener` (registered in `SuiteLifecycleEventRegistrar`) that unlocks the SecretRequests locked at start via `unlockAndUpdateSuite(old, old)`, keeping them on the old suite +- [x] 3.4 Does **not** dispatch `SuiteMigrationCompletedEvent`. `MigrationServiceTest::testAbortDispatchesAbortedEventNotCompleted` asserts the aborted event fires and the completed event does not — the completed event is the only thing `EmergencyAccessSuiteRotationListener` consumes, so this is the unit-level proof envelopes survive an abort +- [x] 3.5 Added `MigrationController::abort(string $id)` (`#[NoAdminRequired]`) with the existing `requireOwnMigration` check; no `#[VaultKeyProofRequired]` (design D6 — abort is restorative) +- [x] 3.6 Registered `migration#abort` → `POST /api/v1/migrations/{id}/abort` +- [x] 3.7 The `compromiseRecovery` refusal already reads "Resume or **abort** that migration before starting another" — that promised route now exists, so the wording is backed rather than broken. Left as-is +- [x] 3.8 Added an "Abort and keep my old key" control to `MigrationResumeBanner.vue` (shown while the banner is expanded), plus the `abortMigration` store action and its vitest coverage (success clears the banner; a 409 refusal keeps it and surfaces the message) + +## 4. Frontend — Producing the Proof + +- [x] 4.1 Add `proveMasterPassword(encryptedPrivateKey, masterPassword, nonce, boundValues)` to `src/crypto/reauth.js`: decrypt the envelope via `decryptPrivateKey` (`src/crypto/aes.js:79`), re-import the PKCS#8 bytes with `['sign']` usage, sign `nonce || sha256(v1) || …`, return the signature +- [x] 4.2 Discard the derived AES key, the raw PKCS#8 bytes and the signing key immediately after signing; never return, store or cache them (spec: *the signing key does not outlive the proof*). Keep `verifyMasterPassword` as-is for the three existing advisory call sites — they are out of scope for this change +- [x] 4.3 Confirm the signing key is imported with `['sign']` only and is NOT the session `CryptoKey`; add a unit test asserting the session key (`src/crypto/rsa.js:61-66`) remains non-extractable and `['decrypt']`-only +- [x] 4.4 Add a shared client helper that fetches a challenge, prompts for the master password, produces the proof, and sets the `X-Keepiq-Key-Proof` header — so the four call sites do not each re-implement it +- [x] 4.5 Wire `src/components/CompromiseRecoveryForm.vue` (recovery start, and the completion call) through the helper +- [x] 4.6 Wire the routine master-password change flow through the helper; verify the old private key is materialised at that point (design "Risks" — if it is not, stop and raise before proceeding) +- [x] 4.7 Wired the emergency-contact delete through the helper: `emergencyAccess.revoke(id, masterPassword)` builds a proof (subject active, bound to the contact id) and `EmergencyAccessView` gained a master-password confirm dialog before it +- [x] 4.8 Completion's proof is now over the OLD key (new middleware subject `migrationOldSuite`), which both the initiate and resume paths already hold the password for — so resume-completion needs no new prompt. The acknowledgement ("Finish anyway") path builds the proof from the retained/re-entered old password, and the form re-shows the password field on a `key_proof_required` refusal + +## 5. Apply The Guard (must not precede section 4) + +- [x] 5.1 `EncryptionSuiteController::compromiseRecovery` → `#[VaultKeyProofRequired(binds: ['publicKey', 'encryptedPrivateKey'])]` +- [x] 5.2 `EncryptionSuiteController::updatePrivateKey` → `#[VaultKeyProofRequired(binds: ['encryptedPrivateKey'], subject: 'routeParam:id')]` +- [x] 5.3 `MigrationController::complete` → `#[VaultKeyProofRequired]` (defence in depth; the acknowledgement stays — they answer different questions) +- [x] 5.4 `EmergencyAccessController::destroy` → `#[VaultKeyProofRequired]` +- [x] 5.5 Create `tests/Unit/Controller/VaultKeyProofAttributesTest.php` in the shape of `RateLimitAttributesTest`: a provider enumerating the four methods with their expected `binds` and `subject`, asserting each by reflection; plus a deliberate-exclusion list (abort, proof-challenge) with the reason recorded per entry + +## 6. Tests + +- [x] 6.1 `tests/Unit/Service/VaultKeyProofServiceTest.php`: valid proof passes; wrong key fails; altered bound value fails; altered nonce fails; expired nonce fails (injected `ITimeFactory`); wrong purpose fails; proof for one parameter set rejected against another +- [x] 6.2 `tests/Unit/Middleware/VaultKeyProofMiddlewareTest.php`: attribute absent → pass-through; attribute present without header → 403 `key_proof_required`; `subject: 'active'` and `'routeParam:id'` both resolve; `afterException` re-throws foreign exceptions +- [x] 6.3 `VaultKeyProofCrossImplTest` verifies a browser-scheme (WebCrypto RSASSA-PKCS1-v1_5 SHA-256) signature with PHP `openssl_verify` over `VaultKeyProofService::signedMessage`; a tampered bound value breaks it. Fixture at `tests/fixtures/vault-key-proof.json`, regenerated by `generate-vault-key-proof-fixture.mjs` +- [x] 6.4 `MigrationServiceTest` covers abort: restores/deletes-successor on an untouched migration; refused-after-commit with the count; idempotent by status; the aborted-not-completed event (the unit-level proof emergency envelopes survive) +- [ ] 6.5 PARTIAL — the guard is enforced by middleware, not the controllers, so a real without-proof 403 needs the request pipeline (out of scope for isolated PHPUnit, same rationale as RateLimitAttributesTest). `VaultKeyProofMiddlewareTest` covers dispatch/refusal and `VaultKeyProofAttributesTest` pins coverage; a full pipeline assertion is a Newman/e2e follow-up +- [ ] 6.6 PARTIAL — finding 2 is closed structurally: the guard sits at rotation ENTRY (`compromiseRecovery`), which the coverage + middleware tests enforce, so garbage-commit can never start. A dynamic end-to-end regression belongs with the §7.6 live reproduction +- [x] 6.7 `tests/vitest/proveMasterPassword.spec.js`: signature verifies against the suite public key; wrong password throws before signing; a changed bound value fails; the session key is pinned non-extractable / decrypt-only. The 403 re-enter path is wired in `CompromiseRecoveryForm` (§4.8) + +## 7. Gates and Documentation + +- [ ] 7.1 Run the hydra gates locally: route-auth (two new routes), no-admin-idor, gate-16 spec-coverage, gate-113 exclusion-evidence (every `@e2e exclude` in this change carries a reason). Note the known pre-existing `no-admin-idor` debt on `development` is not introduced here +- [x] 7.2 Confirmed gate-110 does not apply: no `lib/Migration/` files added and `appinfo/info.xml` `` unchanged (the abort `aborted` status is a plain string-column value) +- [x] 7.3 Documented in `docs/ARCHITECTURE.md` §4.2: the guarded-route table, the attribute contract, the load-bearing design points, and the rule that a new destructive route MUST be added to `VaultKeyProofAttributesTest` +- [x] 7.4 Every branch commit carries `Assisted-by: ClaudeCode:claude-opus-5` and no `Signed-off-by` (keepiq does not require DCO — that is Nextcloud's policy, for nextcloud/* repos) +- [ ] 7.5 The PR description discloses AI tool use, in the contributor's own words, and links issue #395 +- [ ] 7.6 Before opening: re-read #395's "Verification status" — the chain was never executed end to end. Reproduce the lockout on a throwaway account against pre-fix code, then confirm the same steps are refused post-fix. This is the issue's own first task and it is still outstanding diff --git a/openspec/changes/migrate-emergency-access-on-rotation/.openspec.yaml b/openspec/changes/migrate-emergency-access-on-rotation/.openspec.yaml new file mode 100644 index 000000000..e8cda9e50 --- /dev/null +++ b/openspec/changes/migrate-emergency-access-on-rotation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-10 diff --git a/openspec/changes/migrate-emergency-access-on-rotation/design.md b/openspec/changes/migrate-emergency-access-on-rotation/design.md new file mode 100644 index 000000000..8972d9b07 --- /dev/null +++ b/openspec/changes/migrate-emergency-access-on-rotation/design.md @@ -0,0 +1,66 @@ +# Design — migrate-emergency-access-on-rotation + +## Context + +Six stores bind ciphertext to an EncryptionSuite. Five are migrated in the browser during compromise recovery: each record's ciphertext is decrypted with the old private key and re-encrypted to the new one, committed one row per request, and the completion gate refuses until nothing remains on `old_suite_id`. The sixth — `keepiq_emergency_contacts` — is the exception: its recovery envelope is not migrated but *invalidated* by `EmergencyAccessSuiteRotationListener` when `SuiteMigrationCompletedEvent` fires. + +The stated reason is that the owner "cannot re-wrap it alone". That is true of the *old* envelope, whose plaintext is the old private key sealed to the grantee's certificate — opening it needs the grantee's key. It is false of the operation actually wanted: minting a *new* envelope. `buildRecoveryEnvelope(privateKeyPem, granteeCertificatePem)` needs only the grantor's private key and the grantee's public certificate, and during a rotation the owner has both — the new private key is generated locally at `initiateCompromiseRecovery` and the grantee certificate is fetchable. Emergency contacts can therefore migrate like the other stores; the only structural difference is that the job *builds* a value from the new key rather than *transforming* an existing ciphertext. + +## Goals / Non-Goals + +**Goals** +- A compromise-recovery rotation preserves emergency access for every contact whose grantee is still reachable +- The residual — contacts whose grantee cannot be reached — is invalidated as today, but surfaced so the owner can re-designate, instead of lost silently +- No schema change, no new trust assumption + +**Non-Goals** +- Touching the routine master-password-change flow. It keeps the same key pair, so the escrowed private key stays valid and envelopes keep opening; there is nothing to migrate and the invalidation listener does not fire for it +- Migrating a contact to a grantee who has no active suite. The envelope has nowhere to be sealed; that contact is residual by definition +- Preserving the *grantee* side of emergency access when the grantee rotates. That is governed by `invalidateForGranteeRevocation` and is out of scope + +## Decisions + +### D1: Re-envelope in the migration loop, mirroring attachment-grant re-wrap + +Emergency contacts join the migration work list. For each contact still bound to the old suite, the browser fetches the grantee's current certificate, calls `buildRecoveryEnvelope(newPrivateKeyPem, granteeCert)`, and commits the fresh envelope to a migration endpoint that sets `recovery_envelope` and re-points `grantor_suite_id` to the new suite, leaving `state = granted`. + +This reuses the loop, the per-record commit shape, and the server-side owner/suite scoping the other stores already have. The job differs only in its producer: attachment grants decrypt the old wrapped key and re-wrap it; emergency contacts ignore the old envelope entirely and build a new one from the new private key. Both end at "a row that used to point at the old suite now points at the new one, with material only the owner could have produced". + +### D2: Best-effort migrate, listener sweeps the residual — no completion-gate change + +The five migrated stores gate completion: the run cannot finalise while any of their rows remains on the old suite. Emergency contacts are deliberately **not** added to that gate. + +The reason is that a contact can be legitimately un-migratable — the grantee left the instance, or revoked their suite, so there is no certificate to seal to. Gating completion on such a row would trap the vault exactly the way the *A Migration Always Has A Way To Terminate* requirement forbids. So emergency contacts stay outside the gate: the loop migrates every reachable one, and `invalidateForGrantorRotation()` runs at completion as it does today — but now finds only the residual, because the migrated contacts already left the old suite. The listener keeps its current code; its meaning narrows from "invalidate all" to "invalidate whatever the loop could not carry". + +This is the least invasive correct design: no new column, no `migration_error` analogue for contacts, no change to the gate or its progress denominator. The trade-off is that a re-envelope that fails transiently (grantee cert briefly unfetchable) is swept into the residual rather than retried to exhaustion — acceptable, because the residual is re-designatable and the failure mode is "prompt to re-establish", never data loss. + +### D3: The completion summary carries the residual, and the form acts on it + +Today the loss is silent. With this change the completion response reports which contacts were invalidated rather than migrated, and `CompromiseRecoveryForm.vue` prompts the owner to re-designate exactly those. A rotation with all grantees reachable prompts nothing; a rotation with an unreachable grantee explains which one and why. + +### D4: Bind to the grantee's current certificate, and let that be a feature + +The new envelope seals to whatever certificate `getGranteeCertificate()` returns now, which may differ from the one the old envelope used if the grantee has since rotated. This is correct: an envelope sealed to a grantee's stale key would be unopenable by that grantee anyway. Re-enveloping on the grantor's rotation therefore also repairs staleness introduced by the grantee's own rotation, for free. + +### D5: Revocation still clears emergency access — but never silently + +Rotation migrates emergency access (D1); revocation cannot, because it produces no new key to seal to. So revocation keeps clearing the envelopes — but clearing is destructive and irreversible, and revocation is the last-resort route for an owner who lost their master password, i.e. the owner most likely to still need their contact. The safeguard makes the clear a knowing choice: warn plainly, refuse while a usable contact exists unless an explicit override is given, and surface the *count* of usable contacts (never identities — those stay grantor-private) so the administrator can decide. The retrieve-first ordering (accessor pulls the secrets while the suite is still `active`) is the whole point, and it is enforceable rather than merely documented. + +This is folded in here rather than in `harden-vault-key-material-guards` because it is emergency-access-lifecycle behaviour on a suite key-state transition — the same surface D1 already touches — and because the guard change is what makes revocation the only forgotten-password route, so the safeguard is its natural companion. + +## Risks / Trade-offs + +- **A grantee reachable at migration time but not later.** No worse than today: the envelope is valid when built, and any later grantee-side change is handled by the existing grantee-revocation invalidation. Not this change's concern +- **Transient cert-fetch failure demotes a contact to residual.** The owner is prompted to re-designate one contact they did not need to; a nuisance, not a loss. If it proves common, D2 could gain a bounded retry without changing the model +- **Two rotations in quick succession.** The first migrates the envelope to suite B; the second (B→C) re-reads contacts bound to B and migrates again. The work list is derived from `grantor_suite_id`, so this composes without special handling +- **The listener now means something narrower than its name.** `invalidateForGrantorRotation` will mostly invalidate nothing. Worth a comment at the call site so a future reader does not "fix" the apparent no-op + +## Migration Plan + +No data migration. Existing contacts keep working; the first rotation after this ships migrates their envelopes instead of dropping them. A rotation already in progress when this deploys completes under the old behaviour (invalidate) — acceptable, and the owner is prompted to re-designate, which is the pre-change status quo. + +## Open Questions + +- **Gate or sweep?** D2 chooses sweep (no completion-gate change). The alternative — make emergency contacts a gated store with an explicit "invalidate this one" acknowledgement, like the per-record failure path for secrets — is more uniform but needs a contact-level accounting field and touches the gate. Recommendation: ship the sweep; revisit only if the residual needs auditing beyond a re-designation prompt +- **Where does the client read the contacts to process?** DECIDED: the filtered read — the client reads the existing emergency-access index and selects `grantorSuiteId === oldSuiteId`, rather than widening `getWork`. This keeps `getWork`'s `totalRemaining` and the completion gate entirely untouched, which matters because emergency contacts are deliberately not gated (D2). Extending `getWork` was the uniform-looking alternative but would have put a non-gating list inside the endpoint whose whole output feeds the gate. +- **Attachments-style verification?** DECIDED: a shape check, not a round-trip. The other stores verify by decrypting what they just wrote, but an emergency envelope can only be opened by the grantee, so the grantor cannot round-trip it. The server therefore asserts the envelope parses, carries the expected `v`/`alg`, and declares a `granteeSuiteId` matching the grantee's current active suite. This catches a malformed or misaddressed envelope; it cannot catch a well-formed envelope sealed to the wrong plaintext, which is inherent to the trust model and no worse than initial designation, which has the same limit. diff --git a/openspec/changes/migrate-emergency-access-on-rotation/plan.json b/openspec/changes/migrate-emergency-access-on-rotation/plan.json new file mode 100644 index 000000000..2fe92ef71 --- /dev/null +++ b/openspec/changes/migrate-emergency-access-on-rotation/plan.json @@ -0,0 +1,272 @@ +{ + "change": "migrate-emergency-access-on-rotation", + "project": "keepiq", + "repo": "ConductionNL/keepiq", + "base_branch": "development", + "feature_branch": "feature/674/migrate-emergency-access-on-rotation", + "created": "2026-09-10", + "tracking_issue": 674, + "tasks": [ + { + "id": 1, + "num": "1.1", + "title": "Add a read the client can use to enumerate the rotating owner's emergency contacts still bound to the old suite: reuse `EmergencyContactMapper::findByGrantorSuite($oldSuiteId)` filtered to the migration owner, returning `id`, `granteeUserId`, and `state` (exclude already-`invalidated`). Prefer the existing emergency-access index over widening `getWork`, so the completion gate and its progress denominator are untouched", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 2, + "num": "1.2", + "title": "Add a migration re-point endpoint (e.g. `POST /api/v1/migrations/{id}/emergency-contacts/{contactId}`) accepting a fresh `recoveryEnvelope`; it MUST set `recovery_envelope`, set `grantor_suite_id` to the migration's new suite, keep `state = granted`, and clear any `invalidated_reason`", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 3, + "num": "1.3", + "title": "Enforce scoping identically to the other migration writes: refuse unless the contact's current `grantor_suite_id` is the migration's `old_suite_id` and the contact's grantor is the migration owner (resolve the acting user via `OCP\\IUserSession`)", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 4, + "num": "1.4", + "title": "Validate the submitted envelope's shape server-side as far as is possible without the grantee's key: it MUST parse, carry the expected `v`/`alg`, and its declared `granteeSuiteId` MUST match the grantee's current active suite (a shape check, not a round-trip \u2014 only the grantee can open it)", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 5, + "num": "1.5", + "title": "Register the route in `appinfo/routes.php` before the SPA catch-all wildcard", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 6, + "num": "1.6", + "title": "Add a comment at the `invalidateForGrantorRotation()` call site noting it is now a **residual sweep**: after the loop it finds only contacts the migration could not carry (grantee unreachable). Do not \"optimise away\" the apparent no-op", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 7, + "num": "2.1", + "title": "In `initiateCompromiseRecovery` (`src/store/modules/encryptionSuite.js`), after the new key pair is generated and before/within the migration loop, fetch the owner's emergency contacts on the old suite (1.1)", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 8, + "num": "2.2", + "title": "For each contact: fetch the grantee's current certificate via `getGranteeCertificate(granteeUserId)`; on success call `buildRecoveryEnvelope(newPrivateKeyPem, granteeCert)` and POST it to the re-point endpoint (1.2). `newPrivateKeyPem` is already materialised in this function \u2014 reuse it, do not re-derive", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 9, + "num": "2.3", + "title": "On a grantee with no active certificate (fetch throws / returns none), do NOT commit: leave the contact bound to the old suite so the completion sweep invalidates it, and collect it into a `residualContacts` list", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 10, + "num": "2.4", + "title": "Treat a transient re-point failure as residual for this run (the contact is re-designatable); do not halt the migration on it \u2014 emergency contacts are outside the completion gate", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 11, + "num": "2.5", + "title": "The raw new private key PEM MUST stay in the existing rotation scope and MUST NOT be persisted or logged; only envelope ciphertext crosses the wire (ADR-003)", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 12, + "num": "3.1", + "title": "Include `residualContacts` (grantee display names) in the migration outcome returned by `initiateCompromiseRecovery`", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 13, + "num": "3.2", + "title": "In `CompromiseRecoveryForm.vue`, on completion, prompt the owner to re-establish exactly the residual contacts; show nothing about emergency access when every contact migrated", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 14, + "num": "3.3", + "title": "Use `@conduction/nextcloud-vue` components and the NL Design System double-fallback CSS pattern, consistent with the rest of the form", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 15, + "num": "4.1", + "title": "Unit test the re-point endpoint: re-points `grantor_suite_id` to the new suite, keeps `state = granted`, clears `invalidated_reason`; refuses when the contact is on a different suite or owned by another user; rejects a malformed envelope and a `granteeSuiteId` that does not match the grantee's current suite", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 16, + "num": "4.2", + "title": "Unit test the residual sweep: after the loop, `invalidateForGrantorRotation(oldSuiteId)` invalidates only contacts still on the old suite; a migrated contact (now on the new suite) is untouched", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 17, + "num": "4.3", + "title": "Frontend unit test: a reachable grantee yields a `buildRecoveryEnvelope(newPrivateKeyPem, cert)` call and a commit; an unreachable grantee yields no commit and a residual entry", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 18, + "num": "4.4", + "title": "Cross-implementation sanity: an envelope built in JS parses under the server's shape check (config rule: test cross-implementation round-trips as far as the trust model allows)", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 19, + "num": "4.5", + "title": "Regression: a rotation with all grantees reachable prompts no re-designation and leaves no contact invalidated (the behaviour this change fixes)", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 20, + "num": "4.6", + "title": "Two-rotations-in-succession: a contact migrated A\u2192B is then migrated B\u2192C, found each time via `grantor_suite_id`", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 21, + "num": "4b.1", + "title": "On the user-suite revoke path, before clearing, count the owner's usable (non-invalidated) emergency contacts via `EmergencyContactMapper::findByGrantorSuite` / grantor lookup; refuse the revocation when the count is > 0 and no override is supplied, returning that count (never identities)", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 22, + "num": "4b.2", + "title": "Add an explicit `override`/`acceptEmergencyAccessLoss` parameter to the revoke endpoint; with it, revocation proceeds and `clearForGrantorRevocation` runs as today", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 23, + "num": "4b.3", + "title": "Surface the destruction warning in the revoke UI: secrets permanently unreadable + vault rebuilt from scratch; emergency access deleted; if an accessor exists they MUST retrieve secrets first while the suite is still `active`. Use `@conduction/nextcloud-vue` + NL Design System double-fallback CSS", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 24, + "num": "4b.4", + "title": "Tests: revoke refused with the usable-contact count when a contact exists and no override; revoke proceeds and clears with the override; count is returned without identities; no-contact case revokes unchanged", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 25, + "num": "5.1", + "title": "Run the hydra gates locally: route-auth (the re-point route, plus the revoke override param), no-admin-idor (the re-point endpoint is owner-scoped by construction), gate-16 spec-coverage, gate-113 exclusion-evidence (every `@e2e exclude` carries a reason)", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 26, + "num": "5.2", + "title": "Confirm gate-110 does not apply (no migration). If a schema change is introduced after all, bump `appinfo/info.xml` `` from `0.3.1`", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 27, + "num": "5.3", + "title": "Update `docs/ARCHITECTURE.md` where it describes suite migration: emergency contacts are a migrated store, and `invalidateForGrantorRotation` is a residual sweep", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 28, + "num": "5.4", + "title": "Every commit carries `Assisted-by: ClaudeCode:claude-opus-5`; no `Signed-off-by` (only the human certifies the DCO)", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 29, + "num": "5.5", + "title": "PR description discloses AI tool use in the contributor's own words and links the `harden-vault-key-material-guards` change whose open question this resolves", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + } + ] +} diff --git a/openspec/changes/migrate-emergency-access-on-rotation/proposal.md b/openspec/changes/migrate-emergency-access-on-rotation/proposal.md new file mode 100644 index 000000000..7c780c2d2 --- /dev/null +++ b/openspec/changes/migrate-emergency-access-on-rotation/proposal.md @@ -0,0 +1,43 @@ +## Why + +A compromise-recovery rotation silently destroys the user's emergency-access recovery. `EmergencyAccessSuiteRotationListener` fires on `SuiteMigrationCompletedEvent` and calls `invalidateForGrantorRotation()`, which clears the recovery envelope of every emergency contact bound to the old suite. So the one pre-arranged break-glass path a careful user set up is gone after a routine key change, and `CompromiseRecoveryForm.vue` never tells them to re-establish it (verified — the form has no emergency-access copy). This was raised as an open question in the `harden-vault-key-material-guards` change and is the natural fix for it. + +The `encryption-suites` spec presents this destruction as unavoidable. The *Migration Covers Every Suite-Bound Store* requirement says of `keepiq_emergency_contacts`: + +> "the rotating owner cannot re-wrap it alone; the grantor MUST be prompted to re-establish emergency access." + +**That justification is wrong**, and the code proves it. The recovery envelope is built by `buildRecoveryEnvelope(privateKeyPem, granteeCertificatePem)` (`src/crypto/emergencyEnvelope.js`). Re-wrapping the *old* envelope would indeed need the grantee's key — but nobody needs to re-wrap the old one. During a rotation the owner mints a *fresh* envelope escrowing the **new** private key, and both inputs are already in hand: + +- `newPrivateKeyPem` — generated in the browser at the top of `initiateCompromiseRecovery` (`src/store/modules/encryptionSuite.js:186`), the same value that seals every other store in the migration; +- the grantee's current certificate — fetchable via `getGranteeCertificate()`, exactly as initial designation fetches it. + +This is byte-for-byte the operation designation already performs, and structurally identical to the attachment-grant disposition one row up in the same table ("Re-wrap the rotating owner's own grants under the new suite"). Emergency contacts are simply one more suite-bound store that can **migrate** rather than being invalidated. + +Scope is compromise recovery only. A routine master-password change keeps the same RSA key pair and only re-wraps the AES envelope, so the escrowed private key is unchanged and existing recovery envelopes still open — routine change neither invalidates nor needs to migrate them, and the invalidation listener does not fire for it. + +This change also carries the destructive-revocation safeguard from #395's lost-password route. It belongs here rather than in the guard change (`harden-vault-key-material-guards`): once that guard blocks a forgotten-password rotation, administrator revocation becomes the only way back to a working vault, and revocation *deletes* emergency access — so the warning and the ordering gate are emergency-access-lifecycle behaviour, adjacent to the rotation-migration this change already owns. + +## What Changes + +- Migrate emergency-access recovery envelopes as part of compromise-recovery migration: for each of the rotating owner's emergency contacts still bound to the old suite whose grantee has a usable certificate, the browser builds a fresh recovery envelope escrowing the **new** private key, wrapped to the grantee's current certificate, and re-points the contact to the new suite — leaving its `granted` state intact +- Correct the *Migration Covers Every Suite-Bound Store* disposition for `keepiq_emergency_contacts` from "Invalidate, unchanged" to "Re-envelope under the new key where the grantee is reachable; invalidate only the residual" +- Keep `invalidateForGrantorRotation()` as a **fallback sweep**: after migration, it now finds only the contacts that could not be re-enveloped (grantee has no active suite / left the instance), which are exactly the ones that genuinely must be invalidated +- Surface the residual: where any contact was invalidated rather than migrated, prompt the owner to re-designate that specific contact — replacing today's silent, total loss with a targeted, explained one +- Fold in the destructive-revocation safeguard for the lost-password route: revoking a user suite still clears its emergency envelopes, but the system now MUST warn plainly (secrets gone, emergency access **deleted**, accessor must retrieve first while the suite is active), MUST refuse while a usable emergency contact exists unless an explicit override is given, and MUST surface the count of usable contacts (never identities) so the administrator can choose. Today `clearForGrantorRevocation` deletes them silently +- Do **not** change the routine master-password-change flow, which does not rotate the key pair + +## Capabilities + +### Modified Capabilities +- `encryption-suites`: the *Migration Covers Every Suite-Bound Store* requirement gains emergency contacts as a migrated store rather than an invalidated one, with a defined residual disposition +- `emergency-access`: *Envelope Invalidation on Key Change* changes from "rotation invalidates every envelope" to "rotation re-envelopes under the new key where possible and invalidates only the residual" + +## Impact + +- **Database**: none. Re-enveloping reuses the existing `recovery_envelope` and `grantor_suite_id` columns of `keepiq_emergency_contacts`; no schema change, no migration, no `` bump +- **Backend**: `getWork` (or a sibling read) exposes the owner's emergency contacts still bound to the old suite, with the `granteeUserId` needed to fetch the certificate; a migration commit endpoint accepts a fresh envelope and re-points `grantor_suite_id` to the new suite while keeping `state = granted`; `EmergencyAccessSuiteRotationListener` is unchanged in code but now runs as a residual sweep. Owner/suite scoping enforced server-side exactly as the other migration writes are +- **Frontend**: `initiateCompromiseRecovery` builds a new envelope per reachable contact using `buildRecoveryEnvelope(newPrivateKeyPem, granteeCert)` and commits it in the migration loop; the completion summary lists any residual contacts to re-designate; `CompromiseRecoveryForm.vue` renders that prompt +- **Security**: unchanged trust model. The new envelope escrows the new private key and is wrapped to the grantee's public certificate; the raw private key exists only transiently in the browser, and only ciphertext crosses the wire (ADR-003). Binding to the grantee's *current* certificate is strictly more correct than the old envelope, which may have escrowed a key the grantee has since rotated away from +- **Revocation path**: the user-suite revoke flow gains the usable-contact check and the override parameter; the warning copy lives in the settings dialog. `clearForGrantorRevocation` is unchanged in effect (still clears on the override path) but no longer reachable silently +- **Cross-app**: none +- **Dependency note**: composes with `harden-vault-key-material-guards` but does not require it. That change gates *destructive* operations; this one makes a *legitimate* rotation preserve emergency access. Landing this resolves that change's third open question diff --git a/openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md b/openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md new file mode 100644 index 000000000..862a9623f --- /dev/null +++ b/openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md @@ -0,0 +1,54 @@ +## MODIFIED Requirements + +### Requirement: Envelope Invalidation on Key Change +Because the recovery envelope escrows the grantor's private key as of designation, a change to that key MUST be reflected in the envelopes bound to it. + +When the grantor's EncryptionSuite is rotated (compromise recovery), the system MUST migrate each affected recovery envelope where the grantee is reachable: it MUST build a fresh envelope escrowing the grantor's **new** private key, sealed to the grantee's current certificate, and re-point the contact to the new suite while preserving its `granted` state. A contact whose grantee has no active certificate to seal to (the grantee left the instance or revoked their suite) cannot be migrated; the system MUST invalidate that residual contact and MUST prompt the grantor to re-establish it. The grantor MUST NOT be required to open the old envelope to do any of this — building a new envelope needs only the new private key, which the grantor holds during rotation, and the grantee's public certificate. + +Migrating rather than invalidating is possible because the recovery envelope is rebuilt, not re-wrapped: `buildRecoveryEnvelope` takes the grantor's private key and the grantee's public certificate, both of which the grantor has mid-rotation. Sealing to the grantee's *current* certificate is also more correct than preserving the old envelope, which may escrow a key the grantee has since rotated away from. + +When the grantor's EncryptionSuite is revoked, existing recovery envelopes MUST be cleared. Revocation is not a key rotation and produces no new key to migrate to, so unlike rotation there is nothing to migrate the envelope to. But clearing is destructive and irreversible — `clearForGrantorRevocation` deletes the rows outright — and revocation of a user suite is the last-resort route for an owner who has lost their master password, exactly the owner most likely to still need their emergency contact. The system MUST therefore treat this clearing as a decision the acting administrator makes knowingly, not a silent side effect: + +- Before revoking a user suite that has a usable (non-invalidated) emergency contact, the system MUST warn plainly that every secret becomes permanently unreadable and the vault is rebuilt from scratch, that the designated emergency access is **deleted** along with it, and that if an emergency accessor exists they MUST retrieve the old secrets first, while the old suite is still `active`. +- The system MUST refuse the revocation while a usable emergency contact exists, unless the caller supplies an explicit override. The refusal MUST surface the **count** of usable contacts so the administrator can choose — never their identities, which stay grantor-private. Today the deletion is silent and the count is not surfaced; that is the gap this closes. +- With the override, revocation proceeds and clears the envelopes as before. The ordering is enforceable, not merely documented. + +Likewise, if a grantee's EncryptionSuite is revoked, envelopes encrypted to that grantee MUST be invalidated; this is unchanged. + +#### Scenario: Suite rotation migrates a reachable contact +@e2e exclude Server-side re-point plus client-side envelope construction; verifying the migrated envelope opens requires the grantee's key in a second browser context. Covered by PHPUnit on the re-point endpoint and unit tests of the envelope builder. +- **GIVEN** A has an emergency contact B whose EncryptionSuite is active +- **AND** a recovery envelope escrowing A's current private key +- **WHEN** A performs compromise recovery and rotates their EncryptionSuite +- **THEN** the system MUST build a fresh recovery envelope escrowing A's new private key, sealed to B's current certificate +- **AND** re-point the contact to A's new suite with its state still `granted` +- **AND** MUST NOT prompt A to re-establish B + +#### Scenario: Suite rotation invalidates only the unreachable residual +@e2e exclude Server-side listener sweep after the migration loop; covered by PHPUnit (contacts remaining on the old suite are invalidated) and the completion-summary assertion. +- **GIVEN** A has emergency contacts B (active suite) and C (no active suite) +- **WHEN** A performs compromise recovery and rotates their EncryptionSuite +- **THEN** B MUST be migrated to the new suite +- **AND** C MUST be invalidated +- **AND** A MUST be prompted to re-establish C specifically + +#### Scenario: Revocation refuses while a usable emergency contact exists +@e2e exclude Server-side guard on the revoke path; covered by PHPUnit asserting revocation is refused and the usable-contact count is returned. Live UI run deferred. +- **GIVEN** A has a usable (non-invalidated) emergency contact +- **WHEN** an administrator revokes A's suite without an override +- **THEN** the system MUST refuse and MUST report the count of usable emergency contacts +- **AND** MUST NOT clear any recovery envelope or change the suite status +- **AND** MUST NOT disclose the contact's identity + +#### Scenario: Revocation proceeds with an explicit override and warns +@e2e exclude Server-side guard plus the destructive clear; covered by PHPUnit on the revoke path with the override flag. The warning copy is asserted in the settings-dialog component test. +- **GIVEN** A has a usable emergency contact and the administrator has been shown the destruction warning +- **WHEN** the administrator revokes A's suite with the explicit override +- **THEN** the suite MUST be revoked and the recovery envelopes cleared +- **AND** the warning MUST have stated that emergency access is deleted and that an accessor must retrieve secrets first while the suite is still active + +#### Scenario: Suite revocation clears envelopes +@e2e exclude Server-side suite rotation/revocation listener contract — covered by PHPUnit (invalidateForGrantorRotation/clearForGrantorRevocation/invalidateForGranteeRevocation + invalidated audit). Live UI run deferred (worktree not deployed). +- **GIVEN** A has one or more emergency contacts with recovery envelopes +- **WHEN** A's EncryptionSuite is revoked +- **THEN** the recovery envelopes MUST be cleared diff --git a/openspec/changes/migrate-emergency-access-on-rotation/specs/encryption-suites/spec.md b/openspec/changes/migrate-emergency-access-on-rotation/specs/encryption-suites/spec.md new file mode 100644 index 000000000..48bbc6b76 --- /dev/null +++ b/openspec/changes/migrate-emergency-access-on-rotation/specs/encryption-suites/spec.md @@ -0,0 +1,72 @@ +## MODIFIED Requirements + +### Requirement: Migration Covers Every Suite-Bound Store + +The Suite Migration requirement speaks of migrating "all secrets". Because a user's ciphertext is bound to an EncryptionSuite in six separate stores, a migration that walks `keepiq_secrets` alone silently strands the other five. The system MUST therefore treat compromise-recovery migration as complete only when every suite-bound store has been given its disposition. Outstanding work MUST be derivable server-side from the data itself — rows still bound to `old_suite_id` — rather than from a client-reported count, so that a resumed migration knows what remains without trusting the browser. + +The disposition of each store is fixed as follows. All fields listed as re-encrypted are stored as RSA ciphertext; plaintext columns (`name`, `url`, `folder_id`, `requested_fields`) are organisational metadata and MUST NOT be touched. + +| Store | Suite-bound content | Disposition | +|-------|---------------------|-------------| +| `keepiq_secrets` | `key`, `login`, `additional_fields` | Re-encrypt under the new suite; re-point `encryption_suite_id` | +| `keepiq_secret_versions` | `key`, `login`, `additional_fields` (own `encryption_suite_id`) | Re-encrypt the bounded window fixed by the `secret-version-history` spec (head plus the N most recent versions, default 5); drop older versions | +| `keepiq_attachment_grants` | `wrapped_file_key` (RSA-wrapped per-file AES key) | Re-wrap the rotating owner's own grants under the new suite. Grants belonging to other recipients MUST NOT be altered | +| `keepiq_secret_requests` | No ciphertext of its own; `encryption_suite_id` selects the certificate used to encrypt future submissions | Lock for the duration of the migration, then unlock and re-point to the new suite | +| `keepiq_link_shares` | `encrypted_secret_snapshot` | Revoke (cascade), unchanged from current behaviour | +| `keepiq_emergency_contacts` | `recovery_envelope` | Re-envelope under the new key where the grantee is reachable, then invalidate only the residual. For each contact still bound to the old suite whose grantee has an active certificate, the browser builds a fresh envelope escrowing the **new** private key sealed to that certificate and re-points `grantor_suite_id` to the new suite, keeping `state = granted`. A contact whose grantee has no active suite is invalidated and the grantor is prompted to re-establish it. This is not a re-wrap of the old envelope — `buildRecoveryEnvelope` needs only the new private key (held during rotation) and the grantee's public certificate (see the `emergency-access` spec) | + +Re-encryption of `keepiq_secrets`, `keepiq_secret_versions` and `keepiq_attachment_grants` MUST happen in the browser under the same rules as ordinary migration: the old private key decrypts and the new public key encrypts, both as WebCrypto `CryptoKey` objects, and only ciphertext crosses the wire. Emergency contacts are the one migrated store not produced by decrypt-then-re-encrypt: the browser builds a fresh recovery envelope from the new private key and the grantee's fetched certificate, so no old-key decrypt is involved. Unlike the three re-encrypted stores, emergency contacts MUST NOT gate completion — a contact whose grantee is unreachable can never be re-enveloped, and gating on it would make the write lock inescapable; such contacts are swept into invalidation at completion instead. RSA has a per-chunk plaintext cap (446 bytes at RSA-4096), so every value MUST be re-chunked against the new key rather than having its existing chunk framing reused. + +Owner and suite scoping MUST be enforced server-side on every re-encryption write, resolving the acting user through the Nextcloud `OCP\IUserSession` the surrounding controllers already use: a write MUST be refused unless the target row's current `encryption_suite_id` is the migration's `old_suite_id` and the row is owned by the migration's owner. + +#### Scenario: Attachment grants survive the rotation + +@e2e exclude Attachment-grant re-wrapping is verified by unwrapping the file key with the new private key — a WebCrypto/DB assertion with no DOM surface; covered by unit tests of the migration driver and PHPUnit on the re-point endpoint. +- **GIVEN** a user owns a secret with an encrypted attachment, and their own attachment grant holds the file key wrapped under their old suite +- **WHEN** compromise recovery migration completes +- **THEN** the owner's grant MUST hold the same file key re-wrapped under the new suite and the shared ciphertext blob MUST NOT be re-uploaded or duplicated +- **AND** grants held by other recipients of that attachment MUST be unchanged + +#### Scenario: Version history migrates within its bounded window + +@e2e exclude Version-history migration is asserted on stored ciphertext and row counts; the version list UI shows only counts, so the migration itself is not DOM-observable. Covered by PHPUnit and migration-driver unit tests. +- **GIVEN** a secret with a head and 12 prior versions, and a migration window of 5 +- **WHEN** compromise recovery migration completes +- **THEN** the head and the 5 most recent versions MUST be re-encrypted under the new suite and re-pointed +- **AND** the 7 older versions MUST be deleted +- **AND** the user MUST be told that older version history was dropped + +#### Scenario: Secret requests are locked and re-pointed, not stranded + +@e2e exclude The lock/re-point transition is server-side request state; the fill-in page's "temporarily unavailable" surface belongs to the secret-requests spec. Covered by PHPUnit on the request lifecycle. +- **GIVEN** a user has pending SecretRequests when they declare their master password compromised +- **WHEN** the migration starts +- **THEN** those requests MUST be set to `locked` and the fill-in link MUST report the request as temporarily unavailable +- **WHEN** the migration terminates +- **THEN** those requests MUST be unlocked and their `encryption_suite_id` MUST be the new suite + +#### Scenario: A store left unprocessed blocks completion + +@e2e exclude Outstanding-work detection is a server-side query with no DOM representation beyond the aggregate progress indicator; covered by PHPUnit on the completion endpoint. +- **GIVEN** a migration in which the attachment-grant pass has not yet run, so grants remain bound to `old_suite_id` +- **WHEN** the client requests completion of the migration +- **THEN** the server MUST refuse to mark the migration terminal +- **AND** the migration MUST remain `in_progress` with the write lock held + +#### Scenario: A reachable emergency contact is re-enveloped, not invalidated + +@e2e exclude Client builds the envelope and the server re-points the row; verifying the envelope opens needs the grantee's key in a second context. Covered by PHPUnit on the re-point endpoint and unit tests of the envelope builder. +- **GIVEN** a rotating owner with an emergency contact whose grantee has an active suite +- **WHEN** the migration processes emergency contacts +- **THEN** a fresh recovery envelope escrowing the new private key MUST be built and the contact re-pointed to the new suite with `state = granted` +- **AND** the contact MUST NOT be invalidated +- **AND** the completion MUST NOT gate on that contact + +#### Scenario: An unreachable emergency contact does not trap the vault + +@e2e exclude Server-side listener sweep after the loop; covered by PHPUnit asserting the residual is invalidated and completion still terminates. +- **GIVEN** a rotating owner with an emergency contact whose grantee has no active suite +- **WHEN** the migration processes emergency contacts and then completes +- **THEN** that contact MUST be invalidated by the completion sweep +- **AND** completion MUST NOT be blocked by it +- **AND** the owner MUST be prompted to re-establish that specific contact diff --git a/openspec/changes/migrate-emergency-access-on-rotation/tasks.md b/openspec/changes/migrate-emergency-access-on-rotation/tasks.md new file mode 100644 index 000000000..a3452f998 --- /dev/null +++ b/openspec/changes/migrate-emergency-access-on-rotation/tasks.md @@ -0,0 +1,56 @@ +## 0. Read First — Scope and Ordering + +Scope is **compromise-recovery rotation only**. The routine master-password change keeps the same RSA key pair, so escrowed private keys stay valid and this change does not touch that flow (`changePassword` / `updatePrivateKey`). + +No database migration: re-enveloping reuses the existing `recovery_envelope` and `grantor_suite_id` columns of `keepiq_emergency_contacts`. No schema change, no `` bump — gate-110 does not apply. If that assumption changes, revisit. + +Composes with `harden-vault-key-material-guards` but does not depend on it. Landing this resolves that change's third open question (rotation silently costing emergency access). + +Design fork still open (see design.md): the client may read the contacts to migrate either from an extended `getWork` or from the existing emergency-access index filtered by `grantorSuiteId`. Tasks below assume the **filtered read** (smaller blast radius on the completion gate); if `getWork` is chosen instead, 1.1 and 2.2 move accordingly. + +## 1. Backend — Re-point Endpoint and Read + +- [x] 1.1 Add a read the client can use to enumerate the rotating owner's emergency contacts still bound to the old suite: reuse `EmergencyContactMapper::findByGrantorSuite($oldSuiteId)` filtered to the migration owner, returning `id`, `granteeUserId`, and `state` (exclude already-`invalidated`). Prefer the existing emergency-access index over widening `getWork`, so the completion gate and its progress denominator are untouched +- [x] 1.2 Add a migration re-point endpoint (e.g. `POST /api/v1/migrations/{id}/emergency-contacts/{contactId}`) accepting a fresh `recoveryEnvelope`; it MUST set `recovery_envelope`, set `grantor_suite_id` to the migration's new suite, keep `state = granted`, and clear any `invalidated_reason` +- [x] 1.3 Enforce scoping identically to the other migration writes: refuse unless the contact's current `grantor_suite_id` is the migration's `old_suite_id` and the contact's grantor is the migration owner (resolve the acting user via `OCP\IUserSession`) +- [x] 1.4 Validate the submitted envelope's shape server-side as far as is possible without the grantee's key: it MUST parse, carry the expected `v`/`alg`, and its declared `granteeSuiteId` MUST match the grantee's current active suite (a shape check, not a round-trip — only the grantee can open it) +- [x] 1.5 Register the route in `appinfo/routes.php` before the SPA catch-all wildcard +- [x] 1.6 Add a comment at the `invalidateForGrantorRotation()` call site noting it is now a **residual sweep**: after the loop it finds only contacts the migration could not carry (grantee unreachable). Do not "optimise away" the apparent no-op + +## 2. Frontend — Build and Commit the New Envelopes + +- [x] 2.1 In `initiateCompromiseRecovery` (`src/store/modules/encryptionSuite.js`), after the new key pair is generated and before/within the migration loop, fetch the owner's emergency contacts on the old suite (1.1) +- [x] 2.2 For each contact: fetch the grantee's current certificate via `getGranteeCertificate(granteeUserId)`; on success call `buildRecoveryEnvelope(newPrivateKeyPem, granteeCert)` and POST it to the re-point endpoint (1.2). `newPrivateKeyPem` is already materialised in this function — reuse it, do not re-derive +- [x] 2.3 On a grantee with no active certificate (fetch throws / returns none), do NOT commit: leave the contact bound to the old suite so the completion sweep invalidates it, and collect it into a `residualContacts` list +- [x] 2.4 Treat a transient re-point failure as residual for this run (the contact is re-designatable); do not halt the migration on it — emergency contacts are outside the completion gate +- [x] 2.5 The raw new private key PEM MUST stay in the existing rotation scope and MUST NOT be persisted or logged; only envelope ciphertext crosses the wire (ADR-003) + +## 3. Frontend — Surface the Residual + +- [x] 3.1 Include `residualContacts` (grantee display names) in the migration outcome returned by `initiateCompromiseRecovery` +- [x] 3.2 In `CompromiseRecoveryForm.vue`, on completion, prompt the owner to re-establish exactly the residual contacts; show nothing about emergency access when every contact migrated +- [x] 3.3 Use `@conduction/nextcloud-vue` components and the NL Design System double-fallback CSS pattern, consistent with the rest of the form + +## 4. Tests + +- [x] 4.1 Unit test the re-point endpoint: re-points `grantor_suite_id` to the new suite, keeps `state = granted`, clears `invalidated_reason`; refuses when the contact is on a different suite or owned by another user; rejects a malformed envelope and a `granteeSuiteId` that does not match the grantee's current suite +- [x] 4.2 Unit test the residual sweep: after the loop, `invalidateForGrantorRotation(oldSuiteId)` invalidates only contacts still on the old suite; a migrated contact (now on the new suite) is untouched +- [x] 4.3 Frontend unit test: a reachable grantee yields a `buildRecoveryEnvelope(newPrivateKeyPem, cert)` call and a commit; an unreachable grantee yields no commit and a residual entry +- [~] 4.4 Cross-implementation sanity: an envelope built in JS parses under the server's shape check (config rule: test cross-implementation round-trips as far as the trust model allows) — substantially covered: `EmergencyEnvelopeInvalidationServiceTest::testReEnvelopeRepointsToNewSuiteAndKeepsGranted` feeds a JS-shaped envelope (`{v, alg, encKey, iv, ct}`, mirroring `src/crypto/emergencyEnvelope.js`) through the server shape check; a dedicated JS→PHP fixture round-trip is optional follow-up +- [x] 4.5 Regression: a rotation with all grantees reachable prompts no re-designation and leaves no contact invalidated (the behaviour this change fixes) +- [~] 4.6 Two-rotations-in-succession: a contact migrated A→B is then migrated B→C, found each time via `grantor_suite_id` — composes without special handling by construction: the client re-reads all non-invalidated contacts each rotation and the server re-point enforces `grantor_suite_id === old_suite_id`, so a contact on B is carried B→C exactly as A→B. Optional explicit regression test. + +## 4b. Destructive-Revocation Safeguard (lost-password route) + +- [x] 4b.1 On the user-suite revoke path, before clearing, count the owner's usable (non-invalidated) emergency contacts via `EmergencyContactMapper::findByGrantorSuite` / grantor lookup; refuse the revocation when the count is > 0 and no override is supplied, returning that count (never identities) +- [x] 4b.2 Add an explicit `override`/`acceptEmergencyAccessLoss` parameter to the revoke endpoint; with it, revocation proceeds and `clearForGrantorRevocation` runs as today +- [x] 4b.3 Surface the destruction warning in the revoke UI: secrets permanently unreadable + vault rebuilt from scratch; emergency access deleted; if an accessor exists they MUST retrieve secrets first while the suite is still `active`. Use `@conduction/nextcloud-vue` + NL Design System double-fallback CSS +- [x] 4b.4 Tests: revoke refused with the usable-contact count when a contact exists and no override; revoke proceeds and clears with the override; count is returned without identities; no-contact case revokes unchanged + +## 5. Gates and Documentation + +- [x] 5.1 Run the hydra gates locally: route-auth (the re-point route, plus the revoke override param), no-admin-idor (the re-point endpoint is owner-scoped by construction), gate-16 spec-coverage, gate-113 exclusion-evidence (every `@e2e exclude` carries a reason) +- [x] 5.2 Confirm gate-110 does not apply (no migration). If a schema change is introduced after all, bump `appinfo/info.xml` `` from `0.3.1` +- [x] 5.3 Update `docs/ARCHITECTURE.md` where it describes suite migration: emergency contacts are a migrated store, and `invalidateForGrantorRotation` is a residual sweep +- [x] 5.4 Every commit carries `Assisted-by: ClaudeCode:claude-opus-5`; no `Signed-off-by` (only the human certifies the DCO) +- [ ] 5.5 PR description discloses AI tool use in the contributor's own words and links the `harden-vault-key-material-guards` change whose open question this resolves diff --git a/openspec/specs/emergency-access/spec.md b/openspec/specs/emergency-access/spec.md index 0002b384b..aa4112f52 100644 --- a/openspec/specs/emergency-access/spec.md +++ b/openspec/specs/emergency-access/spec.md @@ -9,14 +9,14 @@ The system MUST allow a vault owner (grantor), while their vault is unlocked, to A grantee MUST have an active EncryptionSuite; designating a user with no active suite MUST fail with a clear error (the recovery envelope is encrypted to the grantee's public certificate and cannot be built otherwise). #### Scenario: Designate a contact with a wait period -@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). A live Playwright run of the DOM flow is deferred: the worktree is not deployed and deploying to the shared dev instance is prohibited. +@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). This waiver covers only that server-side state machine, which is not DOM-observable. The DOM flow itself is not excluded, it is uncovered: src/views/EmergencyAccessView.vue is routed at /emergency-access, has an "Emergency access" menu entry and carries data-testid hooks (emergency-access-view, emergency-access-designate, emergency-grantee-input, emergency-wait-select, emergency-master-input), and the E2E Tests (Playwright) job provisions its own throwaway Nextcloud seeded by tests/e2e/ci-seed.sh. A Playwright spec for it is open work and nothing here claims one exists. - **GIVEN** grantor A is unlocked and user B has an active EncryptionSuite - **WHEN** A designates B as an emergency contact with access level `view` and a 7-day wait period - **THEN** the system MUST record the emergency-contact relationship in state `granted` - **AND** it MUST record the access level and wait period #### Scenario: Grantee without an EncryptionSuite is rejected -@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). A live Playwright run of the DOM flow is deferred: the worktree is not deployed and deploying to the shared dev instance is prohibited. +@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). This waiver covers only that server-side state machine, which is not DOM-observable. The DOM flow itself is not excluded, it is uncovered: src/views/EmergencyAccessView.vue is routed at /emergency-access, has an "Emergency access" menu entry and carries data-testid hooks (emergency-access-view, emergency-access-designate, emergency-grantee-input, emergency-wait-select, emergency-master-input), and the E2E Tests (Playwright) job provisions its own throwaway Nextcloud seeded by tests/e2e/ci-seed.sh. A Playwright spec for it is open work and nothing here claims one exists. - **GIVEN** user B has never opened Keepiq and has no EncryptionSuite - **WHEN** grantor A attempts to designate B as an emergency contact - **THEN** the system MUST return an error indicating the grantee has no encryption suite @@ -37,7 +37,7 @@ On designation, the grantor's browser MUST build the recovery envelope entirely The system MUST allow a designated emergency contact to initiate a break-glass request against a grantor who granted them access. Initiating a request MUST move the relationship to state `requested`, record the request time, start the grantor's configured wait period, and notify the grantor. No key material MUST be released at request time. #### Scenario: Contact requests emergency access -@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). A live Playwright run of the DOM flow is deferred: the worktree is not deployed and deploying to the shared dev instance is prohibited. +@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). This waiver covers only that server-side state machine, which is not DOM-observable. The DOM flow itself is not excluded, it is uncovered: src/views/EmergencyAccessView.vue is routed at /emergency-access, has an "Emergency access" menu entry and carries data-testid hooks (emergency-access-view, emergency-access-designate, emergency-grantee-input, emergency-wait-select, emergency-master-input), and the E2E Tests (Playwright) job provisions its own throwaway Nextcloud seeded by tests/e2e/ci-seed.sh. A Playwright spec for it is open work and nothing here claims one exists. - **GIVEN** grantor A has designated B as an emergency contact with a 7-day wait period - **WHEN** B initiates a break-glass request - **THEN** the relationship MUST move to state `requested` with the request time recorded and the 7-day timer started @@ -48,7 +48,7 @@ The system MUST allow a designated emergency contact to initiate a break-glass r At any time before the wait period elapses, the grantor MUST be able to decline a pending break-glass request. Declining MUST move the relationship out of `requested` (to `declined` or back to `granted`), MUST NOT release the recovery envelope, and MUST be recordable together with an optional revocation of the contact. #### Scenario: Grantor declines within the wait window -@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). A live Playwright run of the DOM flow is deferred: the worktree is not deployed and deploying to the shared dev instance is prohibited. +@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). This waiver covers only that server-side state machine, which is not DOM-observable. The DOM flow itself is not excluded, it is uncovered: src/views/EmergencyAccessView.vue is routed at /emergency-access, has an "Emergency access" menu entry and carries data-testid hooks (emergency-access-view, emergency-access-designate, emergency-grantee-input, emergency-wait-select, emergency-master-input), and the E2E Tests (Playwright) job provisions its own throwaway Nextcloud seeded by tests/e2e/ci-seed.sh. A Playwright spec for it is open work and nothing here claims one exists. - **GIVEN** B has a break-glass request pending against A and the wait period has not elapsed - **WHEN** A declines the request - **THEN** the request MUST be rejected and no recovery envelope MUST be released to B @@ -58,7 +58,7 @@ At any time before the wait period elapses, the grantor MUST be able to decline If the wait period elapses on a `requested` relationship without the grantor declining, the system MUST transition it to `approved`. The server MUST release the recovery envelope to the grantee **only** when the relationship is `approved` and the caller is the named grantee; it MUST refuse the envelope in any other state or to any other caller. Once released, the grantee decrypts the envelope with their **own** in-session private key to recover the grantor's private key in their browser, and MAY then read (view) the grantor's secrets. The grantor MUST be notified when the grantee actually accesses the vault. #### Scenario: Timer elapses and the grantee gains view access -@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). A live Playwright run of the DOM flow is deferred: the worktree is not deployed and deploying to the shared dev instance is prohibited. +@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). This waiver covers only that server-side state machine, which is not DOM-observable. The DOM flow itself is not excluded, it is uncovered: src/views/EmergencyAccessView.vue is routed at /emergency-access, has an "Emergency access" menu entry and carries data-testid hooks (emergency-access-view, emergency-access-designate, emergency-grantee-input, emergency-wait-select, emergency-master-input), and the E2E Tests (Playwright) job provisions its own throwaway Nextcloud seeded by tests/e2e/ci-seed.sh. A Playwright spec for it is open work and nothing here claims one exists. - **GIVEN** B has a `requested` relationship against A and the 7-day wait period has elapsed with no decline - **WHEN** the request is evaluated - **THEN** the relationship MUST transition to `approved` @@ -66,13 +66,13 @@ If the wait period elapses on a `requested` relationship without the grantor dec - **AND** A MUST be notified when B accesses the vault #### Scenario: Envelope is refused before approval -@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). A live Playwright run of the DOM flow is deferred: the worktree is not deployed and deploying to the shared dev instance is prohibited. +@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). This waiver covers only that server-side state machine, which is not DOM-observable. The DOM flow itself is not excluded, it is uncovered: src/views/EmergencyAccessView.vue is routed at /emergency-access, has an "Emergency access" menu entry and carries data-testid hooks (emergency-access-view, emergency-access-designate, emergency-grantee-input, emergency-wait-select, emergency-master-input), and the E2E Tests (Playwright) job provisions its own throwaway Nextcloud seeded by tests/e2e/ci-seed.sh. A Playwright spec for it is open work and nothing here claims one exists. - **GIVEN** a break-glass request that is still `requested` (wait period not elapsed) or has been `declined` - **WHEN** the grantee attempts to fetch the recovery envelope - **THEN** the server MUST refuse to release the envelope #### Scenario: Envelope is refused to a non-grantee -@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). A live Playwright run of the DOM flow is deferred: the worktree is not deployed and deploying to the shared dev instance is prohibited. +@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). This waiver covers only that server-side state machine, which is not DOM-observable. The DOM flow itself is not excluded, it is uncovered: src/views/EmergencyAccessView.vue is routed at /emergency-access, has an "Emergency access" menu entry and carries data-testid hooks (emergency-access-view, emergency-access-designate, emergency-grantee-input, emergency-wait-select, emergency-master-input), and the E2E Tests (Playwright) job provisions its own throwaway Nextcloud seeded by tests/e2e/ci-seed.sh. A Playwright spec for it is open work and nothing here claims one exists. - **GIVEN** an `approved` emergency-access relationship between grantor A and grantee B - **WHEN** a user other than B attempts to fetch the recovery envelope - **THEN** the server MUST refuse to release the envelope @@ -81,7 +81,7 @@ If the wait period elapses on a `requested` relationship without the grantor dec The grantor MUST be able to revoke an emergency contact at any time. Revocation MUST delete the recovery envelope and cancel any pending request, and a revoked contact MUST NOT be able to break glass until re-designated (which rebuilds a fresh envelope). #### Scenario: Revoked contact cannot break glass -@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). A live Playwright run of the DOM flow is deferred: the worktree is not deployed and deploying to the shared dev instance is prohibited. +@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest (designate/request/decline/approve-by-timeout + the approved+grantee release gate with identical wrong-state/wrong-caller refusal). This waiver covers only that server-side state machine, which is not DOM-observable. The DOM flow itself is not excluded, it is uncovered: src/views/EmergencyAccessView.vue is routed at /emergency-access, has an "Emergency access" menu entry and carries data-testid hooks (emergency-access-view, emergency-access-designate, emergency-grantee-input, emergency-wait-select, emergency-master-input), and the E2E Tests (Playwright) job provisions its own throwaway Nextcloud seeded by tests/e2e/ci-seed.sh. A Playwright spec for it is open work and nothing here claims one exists. - **GIVEN** A has designated B as an emergency contact - **WHEN** A revokes B - **THEN** the recovery envelope MUST be deleted and any pending request cancelled diff --git a/package-lock.json b/package-lock.json index 14c5e3d97..242c5f6e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "EUPL-1.2", "dependencies": { - "@conduction/nextcloud-vue": "^2.41.1", + "@conduction/nextcloud-vue": "^3.2.0", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "~2.5.2", "@nextcloud/capabilities": "^1.2.1", @@ -98,7 +98,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -187,7 +187,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cacheable/utils": "^2.5.0", @@ -200,7 +200,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hashery": "^1.4.0", @@ -217,7 +217,7 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" @@ -227,7 +227,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hashery": "^1.5.1", @@ -238,7 +238,7 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" @@ -411,9 +411,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.41.1.tgz", - "integrity": "sha512-VpqYxtWoXMACha3Pnw6/vueuX2IUJlF8DLufKSpDVUQkN7mdWaFSy87EfvK/7THUcpYHrtGN5GnLX82MOlgNOg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-3.2.0.tgz", + "integrity": "sha512-jRKOE/xpLnsk9L8i2G6loifDJpRC+ORCsnfkpySDwAT3MRTriKDRXkc/lxfPHxXzXNeCJfiDPEEYbwFHFOUS9Q==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", @@ -430,11 +430,10 @@ "@microsoft/fetch-event-source": "^2.0.1", "@nextcloud/dialogs": "^7.4.1", "@nextcloud/event-bus": "^3.3.3", - "@nextcloud/files": "^3.12.2", + "@nextcloud/files": "^4.0.0", "@nextcloud/notify_push": "^1.4.0", "@nextcloud/password-confirmation": "^6.1.0", "@toast-ui/editor": "^3.2.2", - "@types/react": "^18.0.0", "@uiw/codemirror-theme-github": "^4.25.8", "@vue-flow/background": "^1.3.2", "@vue-flow/core": "^1.48.2", @@ -444,6 +443,7 @@ "ajv-formats": "^3.0.1", "apexcharts": "^4.7.0", "codemirror": "^6.0.0", + "commander": "^14.0.3", "leaflet": "^1.9.0", "leaflet.markercluster": "^1.5.3", "linkifyjs": "^4.3.3", @@ -466,6 +466,7 @@ "@nextcloud/initial-state": "^2.2.0 || ^3.0.0", "@nextcloud/l10n": "^2.0.0 || ^3.0.0", "@nextcloud/router": "^2.0.0 || ^3.0.0", + "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/vue": "^9.0.0", "@vueuse/core": "^11.0.0 || ^14.0.0", "axe-core": "^4.10.0", @@ -474,20 +475,18 @@ "eslint": "^8.56.0 || ^9.0.0 || ^10.0.0", "eslint-plugin-vue": "^9.21.0 || ^10.0.0", "gridstack": "^12.0.0 || ^13.0.0", - "marked": "^12.0.0", + "marked": ">=12 <19", "pinia": "^2.0.0 || ^3.0.0 || ^4.0.0", + "stylelint": "^17.9.1", "vue": "^3.5.0", "vue-eslint-parser": "^9.4.0 || ^10.0.0", "vue-material-design-icons": "^5.0.0" }, "peerDependenciesMeta": { - "axe-core": { - "optional": true - }, - "dexie": { + "@nextcloud/stylelint-config": { "optional": true }, - "dompurify": { + "axe-core": { "optional": true }, "eslint": { @@ -496,7 +495,7 @@ "eslint-plugin-vue": { "optional": true }, - "marked": { + "stylelint": { "optional": true }, "vue-eslint-parser": { @@ -504,6 +503,75 @@ } } }, + "node_modules/@conduction/nextcloud-vue/node_modules/@nextcloud/files": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.0.0.tgz", + "integrity": "sha512-TmecnZIS+PGWGtRh7RpGEboCT4K6iTbHULUcfR6hs3eEzjDVsCc1Ldf8popGY/70lbpdlfYle8xbXnPIo3qaXA==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.2", + "webdav": "^5.9.0" + }, + "engines": { + "node": "^24.0.0" + } + }, + "node_modules/@conduction/nextcloud-vue/node_modules/@nextcloud/sharing": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.3.0.tgz", + "integrity": "sha512-kV7qeUZvd1fTKeFyH+W5Qq5rNOqG9rLATZM3U9MBxWXHJs3OxMqYQb8UQ3NYONzsX3zDGJmdQECIGHm1ei2sCA==", + "license": "GPL-3.0-or-later", + "dependencies": { + "@nextcloud/initial-state": "^3.0.0", + "is-svg": "^6.1.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + }, + "optionalDependencies": { + "@nextcloud/files": "^3.12.0" + } + }, + "node_modules/@conduction/nextcloud-vue/node_modules/@nextcloud/sharing/node_modules/@nextcloud/files": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.2.tgz", + "integrity": "sha512-vBo8tf3Xh6efiF8CrEo3pKj9AtvAF6RdDGO1XKL65IxV8+UUd9Uxl2lUExHlzoDRRczCqfGfaWfRRaFhYqce5Q==", + "license": "AGPL-3.0-or-later", + "optional": true, + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "cancelable-promise": "^4.3.1", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.1", + "webdav": "^5.8.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@conduction/nextcloud-vue/node_modules/@nextcloud/sharing/node_modules/@nextcloud/initial-state": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-3.0.0.tgz", + "integrity": "sha512-cV+HBdkQJGm8FxkBI5rFT/FbMNWNBvpbj6OPrg4Ae4YOOsQ15CL8InPOAw1t4XkOkQK2NEdUGQLVUz/19wXbdQ==", + "license": "GPL-3.0-or-later", + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -603,7 +671,7 @@ "version": "1.1.9", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -648,7 +716,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.1.tgz", "integrity": "sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -671,7 +739,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -1656,7 +1724,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@lezer/common": { @@ -2185,7 +2253,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@nextcloud/stylelint-config/-/stylelint-config-3.2.2.tgz", "integrity": "sha512-5rr77fGK+zoa8yN+8zR43XbqyuN/yB2wSAy4vKE2F+hgAeOhpUAyChV+pfySDbP20t4s22DHgn7BDiZKsbNE3Q==", - "dev": true, + "devOptional": true, "license": "AGPL-3.0-or-later", "dependencies": { "stylelint-use-logical": "^2.1.3" @@ -2963,7 +3031,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2977,7 +3045,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2987,7 +3055,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -4092,7 +4160,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -4312,22 +4380,6 @@ "undici-types": "~8.3.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, "node_modules/@types/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", @@ -5500,7 +5552,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -5513,7 +5565,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5571,7 +5623,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, + "devOptional": true, "license": "Python-2.0" }, "node_modules/assertion-error": { @@ -5650,7 +5702,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5848,7 +5900,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6042,7 +6094,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cacheable/memory": "^2.2.0", @@ -6056,7 +6108,7 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" @@ -6079,7 +6131,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -6290,7 +6342,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6303,14 +6355,14 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/colord": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/colorette": { @@ -6346,7 +6398,6 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -6431,7 +6482,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.1", @@ -6488,7 +6539,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -6547,7 +6598,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -6904,7 +6955,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -6920,7 +6971,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "peer": true, "engines": { @@ -6934,7 +6985,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -6948,7 +6999,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -6974,7 +7025,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -7064,7 +7115,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/encoding": { @@ -7119,7 +7170,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -7150,7 +7201,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -7762,7 +7813,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -7779,7 +7830,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -7861,7 +7912,7 @@ "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4.9.1" @@ -7871,7 +7922,7 @@ "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -7942,7 +7993,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -8152,7 +8203,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -8251,7 +8302,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "global-prefix": "^3.0.0" @@ -8264,7 +8315,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ini": "^1.3.5", @@ -8279,7 +8330,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8305,7 +8356,7 @@ "version": "16.2.4", "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.4.tgz", "integrity": "sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", @@ -8327,7 +8378,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -8337,7 +8388,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/gopd": { @@ -8423,7 +8474,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hookified": "^1.15.0" @@ -8532,7 +8583,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/hosted-git-info": { @@ -8605,7 +8656,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz", "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20.10" @@ -8618,7 +8669,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -8639,7 +8690,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "peer": true, "engines": { @@ -8761,7 +8812,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -8798,7 +8849,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "type": "github", @@ -8825,7 +8876,7 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -8895,7 +8946,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-buffer": { @@ -8944,7 +8995,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -8977,7 +9028,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -8987,7 +9038,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -9012,7 +9063,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -9190,14 +9241,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -9302,7 +9353,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -9332,7 +9383,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9342,7 +9393,7 @@ "version": "0.37.0", "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -9676,7 +9727,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/linkifyjs": { @@ -9728,7 +9779,7 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/longest-streak": { @@ -9876,7 +9927,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", "integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "type": "github", @@ -10107,14 +10158,14 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, + "devOptional": true, "license": "CC0-1.0" }, "node_modules/meow": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -10134,7 +10185,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 8" @@ -10586,7 +10637,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -10600,7 +10651,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -11302,7 +11353,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11493,7 +11544,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -11541,7 +11592,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -11865,7 +11916,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/postcss-html/-/postcss-html-1.8.1.tgz", "integrity": "sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -11882,7 +11933,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -11890,7 +11941,7 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -11961,7 +12012,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -11969,7 +12020,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -11987,7 +12038,7 @@ "version": "4.0.9", "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -12029,7 +12080,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/prebuild-install": { @@ -12323,7 +12374,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hookified": "^2.1.1" @@ -12336,7 +12387,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/quansync": { @@ -12365,7 +12416,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -12634,7 +12685,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -12666,7 +12717,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -12777,7 +12828,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13003,7 +13054,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -13065,7 +13116,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14.16" @@ -13078,7 +13129,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -13382,7 +13433,7 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -13455,7 +13506,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -13586,7 +13637,7 @@ "version": "17.14.1", "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz", "integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -13646,7 +13697,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/stylelint-config-html/-/stylelint-config-html-2.0.0.tgz", "integrity": "sha512-Lk1NPEdUxzHkPv3ehktjpiOk4MPaqQ3H8fkvhxdWlQpaZCm9ze0SoMbRQLqkUxEMfPE1G9/x968uxm/+d2njoA==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -13664,7 +13715,7 @@ "version": "18.0.0", "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-18.0.0.tgz", "integrity": "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -13688,7 +13739,7 @@ "version": "17.0.1", "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-17.0.1.tgz", "integrity": "sha512-x5DVehzJudcwF0od3sGpgkln2PLLranFE7twwbp7dqDINCyZvwzFkMc6TLhNOvazRiVBJYATQLouJY0xPGB8WA==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -13713,7 +13764,7 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/stylelint-config-recommended-vue/-/stylelint-config-recommended-vue-1.6.1.tgz", "integrity": "sha512-lLW7hTIMBiTfjenGuDq2kyHA6fBWd/+Df7MO4/AWOxiFeXP9clbpKgg27kHfwA3H7UNMGC7aeP3mNlZB5LMmEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -13736,7 +13787,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, + "devOptional": true, "license": "ISC", "peer": true, "bin": { @@ -13750,7 +13801,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-7.2.0.tgz", "integrity": "sha512-6E79Bachv0Iz0gqRUZgdqdXCsiq26DWBWIBNHYtjTmAp3wJu6cp/I37VfW7BPntmh2puF3bY09XWl4HZGrLhzw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -13777,7 +13828,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13802,7 +13853,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13826,7 +13877,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13847,7 +13898,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/stylelint-use-logical/-/stylelint-use-logical-2.1.3.tgz", "integrity": "sha512-haPkgxKre+eSqr4IZJnHwNT/9/wICykeFZIaz7rZbe4SohTHkw7vBahMOrZJZpdny/EBVHAcPH2IBeoiUcZWWw==", - "dev": true, + "devOptional": true, "license": "CC0-1.0", "engines": { "node": ">=14.0.0" @@ -13860,7 +13911,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13884,7 +13935,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13907,7 +13958,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13927,7 +13978,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz", "integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13951,7 +14002,7 @@ "version": "11.1.5", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "flat-cache": "^6.1.23" @@ -13961,7 +14012,7 @@ "version": "6.1.23", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cacheable": "^2.5.0", @@ -13973,7 +14024,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -13983,7 +14034,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -14035,7 +14086,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.5.0.tgz", "integrity": "sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^5.0.1", @@ -14052,7 +14103,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -14065,7 +14116,7 @@ "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -14091,7 +14142,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", - "dev": true + "devOptional": true }, "node_modules/symbol-tree": { "version": "3.2.4", @@ -14110,7 +14161,7 @@ "version": "6.9.0", "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "ajv": "^8.0.1", @@ -14127,7 +14178,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -14137,7 +14188,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -14152,7 +14203,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -14333,7 +14384,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -14525,7 +14576,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -15721,7 +15772,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "signal-exit": "^4.0.1" diff --git a/package.json b/package.json index ed7c1392b..a37a3e827 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "libxmljs2": "^0.37.0" }, "dependencies": { - "@conduction/nextcloud-vue": "^2.41.1", + "@conduction/nextcloud-vue": "^3.2.0", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "~2.5.2", "@nextcloud/capabilities": "^1.2.1", diff --git a/phpstan.neon b/phpstan.neon index a4de51b33..8f693634d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -18,6 +18,10 @@ parameters: # classes they extend when the openregister sibling app is absent from the # analysis path. Analysis-only — never loaded at runtime (ADR-040). - tests/stubs/openregister-apphost.stub.php + # Integriq's connection-registry events (adopt-connection-registry), named + # by string behind class_exists in ConnectionReporter (ADR-041). + - tests/stubs/Integriq/Event/ConnectionStatusReportedEvent.php + - tests/stubs/Integriq/Event/ConnectionRefreshRequestedEvent.php ignoreErrors: # Doctrine\DBAL wordings the base does not cover. The base ignores the diff --git a/psalm.xml b/psalm.xml index 2ef0a82f3..28de697f8 100644 --- a/psalm.xml +++ b/psalm.xml @@ -22,6 +22,12 @@ sibling app is absent. Analysis-only — never loaded at runtime. The real classes live in openregister/lib/AppHost/Settings/. --> + + + diff --git a/src/App.vue b/src/App.vue index 7a5757a34..b3b431469 100644 --- a/src/App.vue +++ b/src/App.vue @@ -93,7 +93,7 @@ :aiCompanion="true" :supportDialog="showSupportDialog" :manifest="manifest" - :customComponents="customComponents" + :customComponents="shellCustomComponents" :pageTypes="pageTypes" :registry="registry" appId="keepiq" @@ -204,6 +204,35 @@ ) " /> + + +
+ +
+ + + + {{ + n( + 'keepiq', + 'This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.', + 'This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.', + revokeEmergencyCount, + ) + }} + +
+ :disabled=" + !revokeReason + || !revokePassword + || revoking + " + data-testid="revoke-confirm" + @click=" + handleRevoke(revokeEmergencyCount > 0) + "> {{ revoking ? t('keepiq', 'Revoking…') - : t('keepiq', 'Confirm revocation') + : revokeEmergencyCount > 0 + ? t( + 'keepiq', + 'Revoke and delete emergency access', + ) + : t( + 'keepiq', + 'Confirm revocation', + ) }} + @click="cancelRevoke"> {{ t('keepiq', 'Cancel') }}
@@ -340,6 +384,7 @@ import { NcButton, NcEmptyContent, NcNoteCard, + NcPasswordField, NcSelect, NcTextField, } from '@nextcloud/vue' @@ -359,6 +404,7 @@ import { isPublicSurface, LOCK_ROUTE_NAME, } from './router/guards.js' +import { createConnectionHandlers } from './services/connectionRegistry.js' import { useEncryptionSuiteStore } from './store/modules/encryptionSuite.js' import { useOfflineStore } from './store/modules/offline.js' import { useSessionStore } from './store/modules/session.js' @@ -374,6 +420,7 @@ export default { NcButton, NcEmptyContent, NcNoteCard, + NcPasswordField, NcSelect, NcTextField, TimerIcon, @@ -451,9 +498,12 @@ export default { showRecovery: false, revokeConfirm: false, revokeReason: '', + revokePassword: '', revoking: false, revokeSuccess: false, revokeError: null, + /** @type {number} Usable emergency contacts the server refused to silently delete (0 = none seen). */ + revokeEmergencyCount: 0, timeoutOptions: [ { value: 'session', label: ncT('keepiq', 'Nextcloud session') }, { value: '10min', label: ncT('keepiq', '10 minutes') }, @@ -463,6 +513,29 @@ export default { }, computed: { + /** + * The `customComponents` map CnAppRoot receives: the page components + * main.js derives from src/registry.js, plus the Integrations page's + * Add integration header-action handler (adopt-connection-registry). + * + * The handler is a FUNCTION, because it leaves the app for integriq's + * Connections overview and a header action's `navigate` only pushes a + * route inside this app. CnIndexPage resolves a handler name against + * this map, not against `registry`. + * + * @return {object} Map of name to component or handler. + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page + */ + shellCustomComponents() { + return { + ...this.customComponents, + ...createConnectionHandlers({ + generateUrl, + assign: (url) => window.location.assign(url), + }), + } + }, + /** * Whether this page is being served to an anonymous recipient. * @@ -801,27 +874,61 @@ export default { this.sessionStore.timeout = timeouts[this.sessionTimeout] || 600000 }, + /** + * Dismiss the revoke confirmation, clearing the entered master password + * and any emergency-loss prompt. + * + * @return {void} + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-irreversible-operations-require-a-verified-key-proof + * @spec openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md#requirement-envelope-invalidation-on-key-change + */ + cancelRevoke() { + this.revokeConfirm = false + this.revokeReason = '' + this.revokePassword = '' + this.revokeEmergencyCount = 0 + }, + /** * Revoke the current user's encryption suite from the app shell, - * surfacing success/error state to the UI. + * surfacing success/error state to the UI. The master password signs the + * vault-key proof the guarded endpoint requires and is never sent. * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-7 + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-irreversible-operations-require-a-verified-key-proof */ - async handleRevoke() { + async handleRevoke(acceptEmergencyLoss = false) { this.revoking = true this.revokeError = null this.revokeSuccess = false try { - await this.suiteStore.revokeSuite(this.revokeReason) + await this.suiteStore.revokeSuite( + this.revokeReason, + this.revokePassword, + acceptEmergencyLoss, + ) this.revokeSuccess = true this.revokeConfirm = false this.revokeReason = '' + this.revokePassword = '' + this.revokeEmergencyCount = 0 } catch (e) { - this.revokeError = - e.response?.data?.message - || e.message - || ncT('keepiq', 'Failed to revoke suite') + // The server refuses to silently delete a usable break-glass path. + // Surface the count (never identities) and let the user re-confirm + // with the loss acknowledged, rather than showing a generic error. + if ( + e.response?.status === 409 + && e.response?.data?.error === 'emergency_access_present' + ) { + this.revokeEmergencyCount = + e.response.data.usableEmergencyContacts || 1 + } else { + this.revokeError = + e.response?.data?.message + || e.message + || ncT('keepiq', 'Failed to revoke suite') + } } finally { this.revoking = false } diff --git a/src/components/CompromiseRecoveryForm.vue b/src/components/CompromiseRecoveryForm.vue index e2ec08caa..361004b82 100644 --- a/src/components/CompromiseRecoveryForm.vue +++ b/src/components/CompromiseRecoveryForm.vue @@ -112,13 +112,23 @@ + + +
{{ t('keepiq', 'Try these again') }} {{ n( @@ -188,6 +198,35 @@ + + + - (a.order ?? Number.MAX_SAFE_INTEGER) - - (b.order ?? Number.MAX_SAFE_INTEGER), - ) + const context = { + isAdmin: this.isAdmin, + appsWebRoots: + (typeof window !== 'undefined' && window.OC?.appswebroots) + || null, + } + return (this.manifest?.menu || []) + .filter((item) => isMenuEntryVisible(item, context)) + .sort( + (a, b) => + (a.order ?? Number.MAX_SAFE_INTEGER) + - (b.order ?? Number.MAX_SAFE_INTEGER), + ) }, /** @@ -527,13 +540,16 @@ export default { /** * Router target for a manifest entry (route entries only) — `:to` * keeps the rendered anchors' `href$` shape the e2e suite selects on. + * An entry's `query` rides along, so the Integrations entry opens its + * page preset to `app=keepiq`. * * @param {object} item The menu entry. * @return {object|null} * @spec openspec/specs/menu-architecture/spec.md#app-navigation-renders + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page */ itemTo(item) { - return item.route && !item.action ? { name: item.route } : null + return menuEntryTo(item) }, /** diff --git a/src/components/MigrationResumeBanner.vue b/src/components/MigrationResumeBanner.vue index 666e8ff6a..6a57e7ea8 100644 --- a/src/components/MigrationResumeBanner.vue +++ b/src/components/MigrationResumeBanner.vue @@ -54,6 +54,32 @@ {{ t('keepiq', 'Unlock your vault first, then resume.') }}

+ +
+ + {{ + busy + ? t('keepiq', 'Aborting…') + : t('keepiq', 'Abort and keep my old key') + }} + + + {{ + t( + 'keepiq', + 'Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.', + ) + }} + +
+

{{ progressLabel }}

@@ -222,6 +248,31 @@ export default { this.busy = false } }, + + /** + * Abort the migration, returning the vault to the old key. + * + * On a server refusal (records already moved) the message says so and + * the banner stays, pointing the user at resuming instead. + * + * @return {Promise} + * @spec openspec/changes/harden-vault-key-material-guards/specs/encryption-suites/spec.md#requirement-a-migration-can-be-aborted-before-any-record-moves + */ + async onAbort() { + this.busy = true + this.error = null + + try { + await useEncryptionSuiteStore().abortMigration() + } catch (e) { + this.error = + e?.response?.data?.message + || e?.message + || this.t('keepiq', 'Could not abort the rotation.') + } finally { + this.busy = false + } + }, }, } diff --git a/src/components/settings/AdminSuiteSection.vue b/src/components/settings/AdminSuiteSection.vue new file mode 100644 index 000000000..066d6b623 --- /dev/null +++ b/src/components/settings/AdminSuiteSection.vue @@ -0,0 +1,250 @@ + + + + + + diff --git a/src/components/settings/BreachCheckSection.vue b/src/components/settings/BreachCheckSection.vue index 72238ad12..45b5a020a 100644 --- a/src/components/settings/BreachCheckSection.vue +++ b/src/components/settings/BreachCheckSection.vue @@ -8,10 +8,14 @@ municipal / air-gapped instances never make a surprise external call. The external-call disclosure is shown explicitly. + The root id section-breach-check is the anchor lib/Settings/connections.json links the + Breach check connection to (adopt-connection-registry). Keep it stable. + @spec openspec/changes/password-health/specs/password-health/spec.md#requirement-opt-in-breach-checking-via-k-anonymity --> + + diff --git a/src/icons.js b/src/icons.js index 2b60b9572..93bc70b8c 100644 --- a/src/icons.js +++ b/src/icons.js @@ -27,6 +27,7 @@ import KeyVariant from 'vue-material-design-icons/KeyVariant.vue' import LockOutline from 'vue-material-design-icons/LockOutline.vue' import MapMarkerPath from 'vue-material-design-icons/MapMarkerPath.vue' import Plus from 'vue-material-design-icons/Plus.vue' +import PowerPlugOutline from 'vue-material-design-icons/PowerPlugOutline.vue' import Pulse from 'vue-material-design-icons/Pulse.vue' import ShieldKeyOutline from 'vue-material-design-icons/ShieldKeyOutline.vue' import Sitemap from 'vue-material-design-icons/Sitemap.vue' @@ -48,6 +49,7 @@ export default { LockOutline, MapMarkerPath, Plus, + PowerPlugOutline, Pulse, ShieldKeyOutline, Sitemap, diff --git a/src/manifest.d/80-connection-registry.json b/src/manifest.d/80-connection-registry.json new file mode 100644 index 000000000..6346f142e --- /dev/null +++ b/src/manifest.d/80-connection-registry.json @@ -0,0 +1,86 @@ +{ + "_comment": "ADR-037 manifest fragment. adopt-connection-registry (hydra connection-registry D8, hydra#667, hydra#673, hydra#674 and hydra#676). The rows are integriq's `app_connection` objects, synced from lib/Settings/connections.json; integriq works out each status. The app=keepiq preset is the menu entry's `query` (ADR-097 decision 5), which KeepiqAppNav passes into the route through src/utils/navEntries.js and the index page merges into the fetch as a bare filter key. `showAdd` is false because a row nothing declared has nothing to check (D9). Add integration leaves for integriq's overview through the openIntegriqConnections handler that src/App.vue merges into CnAppRoot's customComponents, because a header action's `navigate` only pushes a route inside this app. The page renders integriq's data, never an admin settings component (ADR-004): the settings stay in AdminSettings.php.", + "menu": [ + { + "id": "IntegrationsMenu", + "label": "Integrations", + "icon": "PowerPlugOutline", + "route": "Integrations", + "query": { + "app": "keepiq" + }, + "section": "settings", + "order": 87, + "permission": "admin", + "visibleIf": { + "appInstalled": "integriq" + } + } + ], + "pages": [ + { + "id": "Integrations", + "route": "/settings/integrations", + "type": "index", + "title": "Integrations", + "permission": "admin", + "requiresApp": { + "id": "integriq", + "name": "Integriq" + }, + "config": { + "register": "integriq", + "schema": "app_connection", + "showViewAction": false, + "showAdd": false, + "headerActions": [ + { + "id": "add-integration", + "label": "Add integration", + "icon": "PowerPlugOutline", + "handler": "openIntegriqConnections" + } + ], + "defaultSort": { + "field": "order", + "direction": "asc" + }, + "columns": [ + { + "key": "title", + "label": "Connection" + }, + { + "key": "status", + "label": "Status", + "formatter": "connectionStatus" + }, + { + "key": "statusMessage", + "label": "Status message", + "sortable": false + }, + { + "key": "checkedAt", + "label": "Last checked" + }, + { + "key": "settingsUrl", + "label": "Settings", + "sortable": false, + "formatter": "connectionSettingsLabel", + "widget": "link", + "widgetProps": { + "href": "{settingsUrl}" + } + } + ], + "folderSidebar": { + "source": "field", + "field": "status", + "allLabel": "All connections" + } + } + } + ] +} diff --git a/src/services/connectionRegistry.js b/src/services/connectionRegistry.js new file mode 100644 index 000000000..4d69458ae --- /dev/null +++ b/src/services/connectionRegistry.js @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + +/** + * The Integrations page's Add integration handler. + * + * The rows on that page are integriq's `app_connection` objects (hydra change + * connection-registry, design D8). Its two formatters, `connectionStatus` and + * `connectionSettingsLabel`, are built into @conduction/nextcloud-vue from + * 3.2.0, so CnAppRoot supplies them and Keepiq no longer carries a copy. + * + * Pure: the URL builder and the navigation are passed in, so + * the module runs under vitest's node environment with nothing mocked. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page + */ + +/** + * Where Add integration lands: integriq's Connections overview, preset to this + * app and opening the link-a-source dialog (hydra connection-registry D9). + */ +export const INTEGRIQ_CONNECTIONS_PATH = + '/apps/integriq/connections?app=keepiq&link=1' + +/** + * Build the Add integration header-action handler. + * + * A FUNCTION handler because a header action's `navigate` keyword only pushes + * a route inside this app's router, which cannot leave the app. + * + * @param {{generateUrl: function(string): string, assign: function(string): void}} deps Builds the instance URL and navigates to it. + * @return {{openIntegriqConnections: function(): void}} The handler, keyed by its manifest name. + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page + */ +export function createConnectionHandlers({ generateUrl, assign }) { + return { + /** + * Open integriq's Connections overview on the link-a-source dialog. + * + * @return {void} + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page + */ + openIntegriqConnections() { + assign(generateUrl(INTEGRIQ_CONNECTIONS_PATH)) + }, + } +} diff --git a/src/store/modules/emergencyAccess.js b/src/store/modules/emergencyAccess.js index defdb16c3..ff8248117 100644 --- a/src/store/modules/emergencyAccess.js +++ b/src/store/modules/emergencyAccess.js @@ -23,6 +23,7 @@ import { openRecoveryEnvelope, } from '../../crypto/emergencyEnvelope.js' import { decryptPrivateKey } from '../../crypto/index.js' +import { buildKeyProofHeaders, PROOF_PURPOSE } from '../../crypto/keyProof.js' import { useSessionStore } from './session.js' export const useEmergencyAccessStore = defineStore('emergencyAccess', { @@ -151,13 +152,29 @@ export const useEmergencyAccessStore = defineStore('emergencyAccess', { /** * Revoke an emergency contact (grantor). * + * Deleting a contact destroys its recovery envelope — the only break-glass + * path that survives a private-key overwrite — so it carries a vault-key + * proof: the caller must prove the master password. The password is used + * only to sign and is never sent. + * * @param {string} id The relationship ID. + * @param {string} masterPassword The current master password, for the proof. * @return {Promise} - * @spec openspec/changes/add-emergency-access/specs/emergency-access/spec.md#requirement-revoke-emergency-contact + * @spec openspec/changes/harden-vault-key-material-guards/specs/emergency-access/spec.md#requirement-revoke-emergency-contact */ - async revoke(id) { + async revoke(id, masterPassword) { + const session = useSessionStore() + const headers = await buildKeyProofHeaders({ + suiteId: session.suiteId, + purpose: PROOF_PURPOSE.EMERGENCY_DESTROY, + encryptedPrivateKey: session.encryptedPrivateKey, + masterPassword, + boundValues: [id], + }) + await axios.delete( generateUrl(`/apps/keepiq/api/v1/emergency-access/contacts/${id}`), + { headers }, ) await this.fetchContacts() }, diff --git a/src/store/modules/encryptionSuite.js b/src/store/modules/encryptionSuite.js index 35306a051..99f229055 100644 --- a/src/store/modules/encryptionSuite.js +++ b/src/store/modules/encryptionSuite.js @@ -1,6 +1,7 @@ import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import { defineStore } from 'pinia' +import { buildRecoveryEnvelope } from '../../crypto/emergencyEnvelope.js' import { decryptPrivateKey, encryptPrivateKey, @@ -8,6 +9,7 @@ import { importPrivateKey, importPublicKey, } from '../../crypto/index.js' +import { buildKeyProofHeaders, PROOF_PURPOSE } from '../../crypto/keyProof.js' import { createMigrationRunner } from '../../migration/driver.js' import { MIGRATION_STORES } from '../../migration/pipeline.js' import { onVaultLock, useSessionStore } from './session.js' @@ -21,6 +23,26 @@ import { onVaultLock, useSessionStore } from './session.js' */ const MIGRATION_CONCURRENCY = 4 +/** + * Serialise a request parameter for a vault-key proof's bound values exactly as + * the server's middleware does — a PHP `(string)` cast of `getParam(name, '')`: + * `true` → `'1'`, `false`/`null`/`undefined` (an absent param) → `''`, anything + * else its string form. The proof commits to the request, so a bound value that + * serialised differently on the two sides would fail every verification. + * + * @param {boolean|number|string|null|undefined} value The parameter value being bound. + * @return {string} The server-matching string form. + */ +function boundParam(value) { + if (value === true) { + return '1' + } + if (value === false || value === null || value === undefined) { + return '' + } + return String(value) +} + export const useEncryptionSuiteStore = defineStore('encryptionSuite', { state: () => ({ /** @type {object|null} Current active suite */ @@ -151,12 +173,24 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { newPassword, ) + // Replacing the envelope is guarded: prove possession of the current + // key (the old master password) over the new envelope. The old key is + // already materialised above, so this adds no extra prompt. + const proof = await buildKeyProofHeaders({ + suiteId: session.suiteId, + purpose: PROOF_PURPOSE.UPDATE_PRIVATE_KEY, + encryptedPrivateKey: session.encryptedPrivateKey, + masterPassword: oldPassword, + boundValues: [newEncryptedPk], + }) + // Update on server. await axios.put( generateUrl( `/apps/keepiq/api/v1/suites/${session.suiteId}/private-key`, ), { encryptedPrivateKey: newEncryptedPk }, + { headers: proof }, ) session.encryptedPrivateKey = newEncryptedPk @@ -179,7 +213,9 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { * @param {string} oldPassword The current master password. * @param {string} newPassword The new master password. * @return {Promise<{migrated: number, failed: number, droppedVersions: number, - * failures: Array, usedWorker: boolean}>} The migration outcome. + * failures: Array, usedWorker: boolean, residualContacts: string[]}>} + * The migration outcome, including the emergency contacts that could not + * be re-enveloped and must be re-established. * @spec openspec/changes/restore-suite-migration-loop/specs/encryption-suites/spec.md#requirement-migration-covers-every-suite-bound-store */ async initiateCompromiseRecovery(oldPassword, newPassword) { @@ -203,12 +239,25 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { this.migrationFailures = [] this.migrationDroppedVersions = 0 + // Starting a rotation is guarded: prove possession of the OLD suite + // key (i.e. the old master password) over the submitted new key + // material, so a stolen session cannot begin a hostile rotation. + const session = useSessionStore() + const startProof = await buildKeyProofHeaders({ + suiteId: session.suiteId, + purpose: PROOF_PURPOSE.COMPROMISE_RECOVERY, + encryptedPrivateKey: session.encryptedPrivateKey, + masterPassword: oldPassword, + boundValues: [publicKeyPem, newEncryptedPk], + }) + const response = await axios.post( generateUrl('/apps/keepiq/api/v1/suites/compromise-recovery'), { publicKey: publicKeyPem, encryptedPrivateKey: newEncryptedPk, }, + { headers: startProof }, ) this.migrationStatus = response.data.migration @@ -224,7 +273,6 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { // The key material is the pair generated above, so this is the same // binding createSuite performs on first-time setup, not a // re-derivation from anything the server sent. - const session = useSessionStore() session.cryptoKey = await importPrivateKey(newPrivateKeyPem) session.encryptedPrivateKey = newEncryptedPk session.certificate = response.data.newSuite?.certificate ?? null @@ -251,15 +299,143 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { newPrivateKey: await importPrivateKey(newPrivateKeyPem), }) + // Migrate emergency-access recovery envelopes BEFORE completion. The + // completion sweep (SuiteMigrationCompletedEvent) invalidates every + // contact still bound to the old suite, so any contact re-enveloped + // here has already left the old suite and survives; the ones that + // could not be carried stay behind for the sweep to invalidate and are + // returned as residual for the form to prompt re-establishment. + // Emergency contacts are outside the completion gate, so this never + // blocks completion (design D2). + outcome.residualContacts = await this.migrateEmergencyContacts({ + migrationId: response.data.migration.id, + oldSuiteId: response.data.migration.oldSuiteId, + newPrivateKeyPem, + }) + // Only now, with nothing left on the old suite, is the vault ready // for the terminal step. The premature complete() that used to sit // here reported success five lines after initiating, before a single // record had been touched. - await this.finaliseMigration(response.data.migration.id, outcome) + // Completion is guarded too, and its proof is over the OLD key — + // the suite being retired — not the new one. That is the key both + // this initiate path and the resume path already hold the password + // for (oldPassword), so completion needs no extra prompt on either. + // Bind every parameter finaliseMigration will send to complete — the + // id, hasErrors and the (here absent) acceptUnrecoverable — so a + // captured proof cannot be replayed to finalise with an acknowledged + // loss it never carried. This path finalises a clean run, so + // acceptUnrecoverable is null and hasErrors follows the outcome. + const completeProof = await buildKeyProofHeaders({ + suiteId: response.data.migration.oldSuiteId, + purpose: PROOF_PURPOSE.COMPLETE_MIGRATION, + encryptedPrivateKey: response.data.oldEncryptedPrivateKey, + masterPassword: oldPassword, + boundValues: [ + response.data.migration.id, + boundParam(outcome.failed > 0), + boundParam(null), + ], + }) + await this.finaliseMigration( + response.data.migration.id, + outcome, + completeProof, + ) return outcome }, + /** + * Migrate the owner's emergency-access recovery envelopes onto the new + * suite during a compromise-recovery rotation. + * + * For each of the owner's non-invalidated contacts the browser fetches the + * grantee's CURRENT certificate, mints a fresh recovery envelope escrowing + * the new private key (never the old one — this is a build, not a re-wrap), + * and posts it to the migration re-point endpoint. A grantee with no active + * certificate, or a transient re-point failure, is not fatal: the contact + * is left on the old suite for the completion sweep to invalidate and its + * grantee is returned as residual so the form can prompt re-establishment. + * + * The raw new private key PEM stays in this rotation scope: it only ever + * leaves as envelope ciphertext, never logged or persisted (ADR-003). + * + * @param {object} params The parameters. + * @param {string} params.migrationId The migration id. + * @param {string} params.oldSuiteId The rotating old suite; only contacts bound to it are carried. + * @param {string} params.newPrivateKeyPem The freshly generated private key PEM. + * @return {Promise} The grantee ids that could not be re-enveloped. + * @spec openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md#requirement-envelope-invalidation-on-key-change + */ + async migrateEmergencyContacts({ + migrationId, + oldSuiteId, + newPrivateKeyPem, + }) { + const residualContacts = [] + + let contacts + try { + const response = await axios.get( + generateUrl('/apps/keepiq/api/v1/emergency-access/contacts'), + ) + contacts = Array.isArray(response.data) ? response.data : [] + } catch { + // Could not enumerate the contacts — none are re-enveloped and the + // completion sweep invalidates them all, the pre-change behaviour. + return residualContacts + } + + for (const contact of contacts) { + // An already-invalidated contact has no envelope to carry. + if (contact.state === 'invalidated') { + continue + } + + // Only THIS rotation's contacts belong here. A contact stranded on + // a prior suite (grantorSuiteId !== the rotating old suite) was not + // lost in this rotation, so skip it silently rather than posting it + // (the server would refuse it, grantorSuiteId !== oldSuiteId) and + // mislabelling it as a residual this rotation dropped. + if (contact.grantorSuiteId !== oldSuiteId) { + continue + } + + try { + const certResponse = await axios.get( + generateUrl( + '/apps/keepiq/api/v1/emergency-access/grantee-certificate', + ), + { params: { granteeUserId: contact.granteeUserId } }, + ) + + const recoveryEnvelope = await buildRecoveryEnvelope( + newPrivateKeyPem, + certResponse.data.certificate, + ) + + await axios.post( + generateUrl( + `/apps/keepiq/api/v1/migrations/${migrationId}/emergency-contacts/${contact.id}`, + ), + { + recoveryEnvelope, + granteeSuiteId: certResponse.data.suiteId, + }, + ) + } catch { + // Grantee unreachable (no active certificate) or a transient + // re-point failure: leave the contact on the old suite for the + // completion sweep to invalidate, and prompt re-establishment. + // Never fatal — emergency contacts are outside the gate. + residualContacts.push(contact.granteeUserId) + } + } + + return residualContacts + }, + /** * Drive the re-encryption loop until no work remains. * @@ -651,16 +827,22 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { * * @param {string} migrationId The migration ID. * @param {object} outcome The run outcome from runMigration. + * @param {Record|null} proofHeaders Vault-key-proof headers for completion. * @return {Promise<{finalised: boolean, needsAcknowledgement: boolean, message: string|null}>} * Whether the migration terminated, and why not if it did not. * @spec openspec/changes/restore-suite-migration-loop/specs/encryption-suites/spec.md#requirement-migration-covers-every-suite-bound-store */ - async finaliseMigration(migrationId, outcome) { + async finaliseMigration(migrationId, outcome, proofHeaders = null) { this.migrationNeedsAcknowledgement = false this.migrationBlockedMessage = null try { - await this.completeMigration(migrationId, outcome.failed > 0) + await this.completeMigration( + migrationId, + outcome.failed > 0, + null, + proofHeaders, + ) return { finalised: true, needsAcknowledgement: false, @@ -706,11 +888,16 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { * called from an affirmative user action. * * @param {string} migrationId The migration ID. + * @param {string} oldPassword The old master password, to prove the retiring key. * @param {number} acceptUnrecoverable How many losses the user accepted. * @return {Promise} The completion response. * @spec openspec/changes/restore-suite-migration-loop/specs/secrets/spec.md#requirement-possibly-compromised-flag-lifecycle */ - async acceptMigrationLosses(migrationId, acceptUnrecoverable = null) { + async acceptMigrationLosses( + migrationId, + oldPassword, + acceptUnrecoverable = null, + ) { // Defaults to the server's own number. A caller may still pass one // explicitly, but the stored value is what the server asked for and // is therefore what it will accept. @@ -723,7 +910,40 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { ) } - const data = await this.completeMigration(migrationId, true, accepted) + // Completing is guarded, and the proof is over the OLD (retiring) + // key. Without the old password we cannot build it, so ask for it + // rather than sending a request the server will refuse. + if (!oldPassword) { + const err = new Error( + 'Re-enter your master password to finish the rotation.', + ) + err.code = 'key_proof_required' + throw err + } + const { data: oldSuite } = await axios.get( + generateUrl( + `/apps/keepiq/api/v1/suites/${this.migrationStatus.oldSuiteId}`, + ), + ) + // Bind the acknowledged count: this is the exact replay Wilco flagged — + // a proof that committed only to the id could be captured on a clean + // completion and re-presented here to finalise with an unacknowledged + // permanent loss. Binding hasErrors (always true on this path) and the + // accepted count closes it. + const proof = await buildKeyProofHeaders({ + suiteId: this.migrationStatus.oldSuiteId, + purpose: PROOF_PURPOSE.COMPLETE_MIGRATION, + encryptedPrivateKey: oldSuite.privateKey, + masterPassword: oldPassword, + boundValues: [migrationId, boundParam(true), boundParam(accepted)], + }) + + const data = await this.completeMigration( + migrationId, + true, + accepted, + proof, + ) this.migrationNeedsAcknowledgement = false this.migrationRequiredAcknowledgement = null this.migrationBlockedMessage = null @@ -741,10 +961,16 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { * @param {string} migrationId The migration ID. * @param {boolean} hasErrors Whether any record failed. * @param {number|null} acceptUnrecoverable Losses the user has accepted. + * @param {Record|null} proofHeaders Vault-key-proof headers for completion. * @return {Promise} The completion response body. * @spec openspec/changes/restore-suite-migration-loop/specs/encryption-suites/spec.md#requirement-a-migration-always-has-a-way-to-terminate */ - async completeMigration(migrationId, hasErrors, acceptUnrecoverable = null) { + async completeMigration( + migrationId, + hasErrors, + acceptUnrecoverable = null, + proofHeaders = null, + ) { try { const body = { hasErrors } // The server refuses to finalise a migration that would cost the @@ -755,11 +981,15 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { body.acceptUnrecoverable = acceptUnrecoverable } + // Completing marks the old suite compromised, so it carries a + // vault-key proof (defence in depth alongside the acknowledgement). + const config = proofHeaders ? { headers: proofHeaders } : {} const { data } = await axios.post( generateUrl( `/apps/keepiq/api/v1/migrations/${migrationId}/complete`, ), body, + config, ) this.migrationDroppedVersions = data.droppedVersions ?? this.migrationDroppedVersions @@ -876,7 +1106,25 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { newPrivateKey: session.cryptoKey, }) - await this.finaliseMigration(migrationId, outcome) + // Completion's proof is over the OLD key, which resume already holds + // the password for — so a resumed run finalises without any extra + // prompt, exactly like the initiate path. + // Bind every parameter finaliseMigration sends to complete (id, + // hasErrors, and the absent acceptUnrecoverable), matching the initiate + // path — a proof that commits only to the id could be re-aimed to + // acknowledge a loss. + const completeProof = await buildKeyProofHeaders({ + suiteId: this.migrationStatus.oldSuiteId, + purpose: PROOF_PURPOSE.COMPLETE_MIGRATION, + encryptedPrivateKey: oldSuite.privateKey, + masterPassword: oldPassword, + boundValues: [ + migrationId, + boundParam(outcome.failed > 0), + boundParam(null), + ], + }) + await this.finaliseMigration(migrationId, outcome, completeProof) return outcome }, @@ -915,19 +1163,44 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { /** * Revoke the current user's active encryption suite. * + * When the suite still has a usable emergency contact the server refuses + * with 409 `emergency_access_present` and the count of usable contacts, + * unless `acceptEmergencyLoss` is set — revocation permanently deletes that + * emergency access. The refusal propagates as a rejected request for the + * caller to surface and re-confirm. + * * @param {string} reason The reason for revocation + * @param {string} masterPassword The master password, to sign the vault-key proof + * @param {boolean} acceptEmergencyLoss Proceed even though emergency access will be deleted * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-7 + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-irreversible-operations-require-a-verified-key-proof + * @spec openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md#requirement-envelope-invalidation-on-key-change */ - async revokeSuite(reason) { + async revokeSuite(reason, masterPassword, acceptEmergencyLoss = false) { if (!this.currentSuite) { throw new Error('No active suite to revoke') } + // Revocation is guarded by a vault-key proof: prove possession of the + // master password over this suite's private key, binding the reason AND + // the emergency-loss flag so a proof captured on a no-override revoke + // cannot be replayed with acceptEmergencyLoss=true to force emergency + // deletion. A stolen session has no master password and cannot revoke. + const session = useSessionStore() + const proof = await buildKeyProofHeaders({ + suiteId: this.currentSuite.id, + purpose: PROOF_PURPOSE.REVOKE_SUITE, + encryptedPrivateKey: session.encryptedPrivateKey, + masterPassword, + boundValues: [reason, boundParam(acceptEmergencyLoss)], + }) + const response = await axios.post( generateUrl( `/apps/keepiq/api/v1/suites/${this.currentSuite.id}/revoke`, ), - { reason }, + { reason, acceptEmergencyLoss }, + { headers: proof }, ) this.currentSuite = response.data @@ -941,6 +1214,87 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { } }, + /** + * Administrator force-revoke of any suite by id (admin settings surface). + * + * The administrator counterpart to the owner path's `revokeSuite()`. The + * vault is zero-knowledge (ADR-003), so an administrator holds no vault key + * to sign the revoke challenge; authorisation is the admin guard plus + * Nextcloud sudo. The endpoint carries `#[PasswordConfirmationRequired]`, so + * the password-confirmation (sudo) flow MUST complete BEFORE the request — + * the middleware rejects a request whose sudo has not been re-confirmed. + * + * `@nextcloud/password-confirmation` is imported lazily (like the offline + * store below) so its `@nextcloud/vue` dialog dependency stays off the + * store's static load path; `confirmPassword()` resolves immediately when + * sudo is not currently required and otherwise prompts, resolving only once + * the administrator has re-confirmed and rejecting if they cancel. + * + * The offline cache is deliberately NOT evicted here: it holds the acting + * administrator's OWN vault, not the (cross-owner) target suite's secrets, + * so evicting it on an unrelated admin action would be wrong. The owner + * `revokeSuite()` evicts because there the revoked suite IS the caller's own. + * + * @param {object} params The parameters. + * @param {string} params.id The suite id to force-revoke. + * @param {string} params.reason The required, free-form revocation reason. + * @param {boolean} params.markCompromised Treat the suite's secrets as compromised (default false). + * @return {Promise<{suite: object, emergencyContactsDestroyed: number, warning: string|null}>} + * The revoked suite, the count of destroyed usable emergency contacts, and + * (only when `markCompromised` was false) the rotation-may-be-warranted warning. + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + */ + async forceRevokeSuite({ id, reason, markCompromised = false }) { + if (!id) { + throw new Error('No suite id to revoke') + } + if (!reason) { + throw new Error('A reason is required to force-revoke a suite') + } + + // Complete Nextcloud sudo BEFORE issuing the request — the endpoint's + // #[PasswordConfirmationRequired] middleware rejects it otherwise. + const { confirmPassword } = + await import('@nextcloud/password-confirmation') + await confirmPassword() + + const response = await axios.post( + generateUrl(`/apps/keepiq/api/v1/suites/${id}/force-revoke`), + { reason, markCompromised }, + ) + + return { + suite: response.data, + emergencyContactsDestroyed: + response.data.emergencyContactsDestroyed ?? 0, + warning: response.data.warning ?? null, + } + }, + + /** + * Reinstate a revoked suite by id from the admin settings surface. + * + * Wired to the existing admin-only `reinstate()` endpoint, which carries no + * `#[PasswordConfirmationRequired]` — the `AuthorizedAdminSetting` guard is + * the authorization, so no sudo flow is needed. Frontend-only; the endpoint + * and `reinstateSuite()` service are unchanged. + * + * @param {string} id The suite id to reinstate. + * @return {Promise} The reinstated suite JSON. + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + */ + async reinstateSuiteAdmin(id) { + if (!id) { + throw new Error('No suite id to reinstate') + } + + const response = await axios.post( + generateUrl(`/apps/keepiq/api/v1/suites/${id}/reinstate`), + ) + + return response.data + }, + /** * Check migration status. * @@ -957,5 +1311,41 @@ export const useEncryptionSuiteStore = defineStore('encryptionSuite', { this.migrationStatus = null } }, + + /** + * Abort an interrupted migration, returning the vault to the old suite. + * + * The non-destructive escape from a rotation the user does not want to + * finish: it discards the unused new key and unlocks the vault under the + * old key, which never stopped being valid. The server refuses (409) if + * any record has already moved to the new suite — at that point resuming + * is the only safe route — so this surfaces that as an error for the + * banner rather than pretending it succeeded. + * + * @return {Promise} The server's terminal result. + * @spec openspec/changes/harden-vault-key-material-guards/specs/encryption-suites/spec.md#requirement-a-migration-can-be-aborted-before-any-record-moves + */ + async abortMigration() { + await this.fetchMigrationStatus() + if (this.migrationStatus === null) { + throw new Error('There is no migration to abort') + } + + const migrationId = this.migrationStatus.id + try { + const { data } = await axios.post( + generateUrl( + `/apps/keepiq/api/v1/migrations/${migrationId}/abort`, + ), + ) + return data + } finally { + // Whether it aborted or was refused, re-read the authoritative + // state so the banner reflects reality (cleared on success, still + // present with its remaining count on a refusal). + await this.fetchMigrationStatus() + await this.fetchMigrationRemaining() + } + }, }, }) diff --git a/src/utils/navEntries.js b/src/utils/navEntries.js new file mode 100644 index 000000000..b9d57fcb9 --- /dev/null +++ b/src/utils/navEntries.js @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + +/** + * How Keepiq's own navigation rail reads one manifest menu entry. + * + * KeepiqAppNav replaces CnAppNav in CnAppRoot's `#menu` slot, so it gets none + * of CnAppNav's entry handling for free. It used to read only `route`, `href` + * and `action`. The Integrations entry (adopt-connection-registry) is the + * first to declare three more fields, and each one is silent when dropped: + * + * - `query`: without it the Integrations page opens with no `app=keepiq` + * preset and lists every app's connection rows as though they were + * Keepiq's. + * - `permission: "admin"`: without it every user sees an admin-only entry. + * - `visibleIf.appInstalled`: without it the entry shows on an instance + * without integriq and leads to a missing-dependency screen. + * + * The rules follow CnAppNav (`itemTo`, `passesVisibleIf` and + * `isAppInstalled`). They differ in one place: CnAppNav checks `permission` + * against a permission list Nextcloud does not provide, so it renders every + * entry. Here `admin` means the instance admin flag. No other entry in the + * manifest declares `permission` or `visibleIf`. + * + * Pure: the admin flag and the enabled apps are passed in. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page + */ + +/** + * The router target for a route entry, with its `query` preset when it has one. + * + * @param {object} item The menu entry. + * @return {object|null} A vue-router location, or null for a non-route entry. + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page + */ +export function menuEntryTo(item) { + if (!item?.route || item.action) { + return null + } + + const query = item.query + if (query && typeof query === 'object' && Object.keys(query).length > 0) { + return { name: item.route, query: { ...query } } + } + + return { name: item.route } +} + +/** + * Whether a menu entry may render for this user on this instance. + * + * @param {object} item The menu entry. + * @param {{isAdmin: boolean, appsWebRoots: object|null|undefined}} context The instance admin flag, and `OC.appswebroots`: one key per app enabled for this user. + * @return {boolean} False when the entry is admin only and the user is not an admin, or names an app that is not enabled. + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page + */ +export function isMenuEntryVisible(item, { isAdmin, appsWebRoots }) { + if (item?.permission === 'admin' && isAdmin !== true) { + return false + } + + const required = item?.visibleIf?.appInstalled + if (typeof required === 'string' && required.length > 0) { + // Hide on uncertainty, like CnAppNav: a missing map is not an installed app. + return Boolean(appsWebRoots) && Object.hasOwn(appsWebRoots, required) + } + + return true +} diff --git a/src/views/EmergencyAccessView.vue b/src/views/EmergencyAccessView.vue index 79c447e0a..c132942ff 100644 --- a/src/views/EmergencyAccessView.vue +++ b/src/views/EmergencyAccessView.vue @@ -90,7 +90,7 @@ + @click="promptRevoke(c.id)"> {{ t('keepiq', 'Revoke') }} @@ -145,6 +145,18 @@ }}

+ + + @@ -156,6 +168,7 @@ import { NcSelect, NcTextField, } from '@nextcloud/vue' +import EmergencyRevokeDialog from '../dialogs/EmergencyRevokeDialog.vue' import { useEmergencyAccessStore } from '../store/modules/emergencyAccess.js' /** @@ -176,6 +189,7 @@ export default { NcPasswordField, NcSelect, NcEmptyContent, + EmergencyRevokeDialog, }, data() { @@ -187,6 +201,11 @@ export default { busy: false, error: '', recovered: false, + /** @type {string|null} The contact id awaiting a revoke confirmation. */ + revokeTarget: null, + revokePassword: '', + revoking: false, + revokeError: '', } }, @@ -270,14 +289,53 @@ export default { }, /** - * Revoke a designated contact. + * Open the master-password confirmation for revoking a contact. * * @param {string} id The relationship ID. + * @return {void} + * @spec openspec/changes/harden-vault-key-material-guards/specs/emergency-access/spec.md#requirement-revoke-emergency-contact + */ + promptRevoke(id) { + this.revokeTarget = id + this.revokePassword = '' + this.revokeError = '' + }, + + /** + * Dismiss the revoke confirmation without acting. + * + * @return {void} + * @spec openspec/changes/harden-vault-key-material-guards/specs/emergency-access/spec.md#requirement-revoke-emergency-contact + */ + cancelRevoke() { + this.revokeTarget = null + this.revokePassword = '' + this.revokeError = '' + }, + + /** + * Revoke the pending contact, proving the master password. + * * @return {Promise} - * @spec openspec/changes/add-emergency-access/specs/emergency-access/spec.md#requirement-revoke-emergency-contact + * @spec openspec/changes/harden-vault-key-material-guards/specs/emergency-access/spec.md#requirement-revoke-emergency-contact */ - async revoke(id) { - await this.store.revoke(id) + async confirmRevoke() { + this.revoking = true + this.revokeError = '' + try { + await this.store.revoke(this.revokeTarget, this.revokePassword) + this.cancelRevoke() + } catch (e) { + this.revokeError = + e?.response?.data?.message + || e?.message + || this.t( + 'keepiq', + 'Could not revoke. Check your master password.', + ) + } finally { + this.revoking = false + } }, /** diff --git a/src/views/settings/Settings.vue b/src/views/settings/Settings.vue index d1d214d07..630c1dc2c 100644 --- a/src/views/settings/Settings.vue +++ b/src/views/settings/Settings.vue @@ -26,12 +26,14 @@ +