diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index aa53e3117..ace9a852f 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -118,38 +118,52 @@ jobs: app-name: keepiq php-version: "8.3" php-test-versions: '["8.3", "8.4"]' - # Order matters: the PHPUnit matrix uses the whole list, but the E2E - # (Playwright), Newman and Journeydoc jobs all check out - # `fromJSON(nextcloud-test-refs)[0]` as their single server. That server - # has to be one OpenRegister can load, because `additional-apps` below - # installs it and keepiq's AppHost integration delegates to it — - # OpenRegister's lib/ContextChat/ContentProvider.php implements - # `OCP\ContextChat\IContentProvider`, which exists in stable32 and NOT in - # stable31 (verified: raw.githubusercontent.com returns 404 for the - # stable31 path). On stable31 every `occ` invocation printed + # NO `nextcloud-test-refs` HERE, DELIBERATELY. The shared workflow derives + # the matrix from appinfo/info.xml when this input is unset, and a derived + # matrix cannot disagree with the declared range. The override that used to + # sit here is exactly how the two came apart: it read + # `["stable34", "stable32", "stable33"]` while this branch moved info.xml to + # ``, so NC 35 was advertised + # to the App Store with no job touching it — not known-broken, unmeasured. + # gate-65 rule 11 is what caught it. Re-adding stable35 by hand fixes today + # and leaves the next bump to be remembered in two files; deriving deletes + # the second file. + # + # WHAT THE OVERRIDE WAS PROTECTING, AND WHY IT NO LONGER HAS TO. + # Its ORDER carried meaning: E2E (Playwright), Newman and journeydoc-capture + # each ran against `fromJSON(nextcloud-test-refs)[0]`, so stable34 had to + # lead to keep them off a server OpenRegister cannot load — OpenRegister's + # lib/ContextChat/ContentProvider.php implements + # `OCP\ContextChat\IContentProvider`, which does not exist before stable32, + # and on stable31 every `occ` invocation printed # `Interface "OCP\ContextChat\IContentProvider" not found` while loading # commands from openregister's info.xml. # - # The earlier reorder fixed the FIRST-entry problem for E2E/Newman/Journeydoc - # but left "stable31 is still covered by the PHPUnit matrix" — and that leg - # is broken by the same fact. The phpunit job ALSO installs `additional-apps` - # (shared quality.yml, "Checkout additional apps" + "Enabling app: …"), and - # its `occ app:enable openregister` failure is only a ::warning::, so the - # stable31 leg ran on without OpenRegister loaded. openregister has since - # made the floor explicit — ``, 8d5181f7a — so - # NC31 is now a configuration this fixture cannot produce at all. + # Those four jobs no longer read a meaning off a position. The shared + # workflow computes a `single-server` output — the numerically HIGHEST + # stable branch in the resolved set, and for the derived path the matrix + # action's own `branches-max` — and they consume that. The action's + # `branches` output is OLDEST-first, so a positional read would have moved + # all four onto stable32 silently. List order is now inert, and the one + # thing the override bought is structural instead. + # + # THIS ADDS A stable35 LEG. It was RED BEFORE IT WAS GREEN, deliberately. + # `additional-apps` below installs openregister and integriq, and when this + # branch was opened (2026-09-15) BOTH still declared `max-version="34"` on + # `development`. The shared workflow aborts the job when `occ app:enable` + # fails for an additional app — that was once only a ::warning::, which is + # how a leg previously ran on WITHOUT OpenRegister loaded and reported + # nothing — so the stable35 legs failed at the fixture, not in the tests. # - # Removing stable31 corrects an impossible configuration; it does not reduce - # coverage, because nothing was being covered on that leg. + # RESOLVED 2026-09-24: both dependencies now declare + # `` on `development`, which + # is the ref this file pins. Verified by parsing each appinfo/info.xml + # rather than grepping, because both files carry comments that quote OTHER + # apps' ranges and a grep matches those first. # - # THE LIST IS THE WHOLE DECLARED RANGE. appinfo/info.xml declares - # , so 32, 33 and 34 each get - # a leg. Adopting NC 34 by REPLACING the list left 32 and 33 advertised to - # the App Store with no job touching them — the declared floor became the - # untested end, which is the same drift as never testing 34, reversed. - # stable34 leads because newman, playwright and journeydoc-capture all read - # `fromJSON(inputs.nextcloud-test-refs)[0]` as their single server. - nextcloud-test-refs: '["stable34", "stable32", "stable33"]' + # The point of deriving the matrix from info.xml stands: the red was the + # honest state of NC 35 support while the gap existed, and nothing here + # could hide it. Leave it derived. enable-psalm: true enable-phpstan: true enable-phpmetrics: true @@ -172,7 +186,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 ──────────────────────────────────────────────────────── @@ -234,9 +255,8 @@ jobs: # formatter failure mode that made the old `.prettierrc` worth deleting. # Centralising the config never stopped drift; the gate does. # Measured on this tree before enabling: PASSES, 294 of 310 tracked - # frontend files in scope. This repo has TWO documentation trees and both - # are excluded by .prettierignore — `docs/` and the separate `docusaurus/` - # site, which has its own package.json and its own toolchain. + # frontend files in scope. The documentation site in `docs/` is excluded + # by .prettierignore: it has its own package.json and its own toolchain. # `check:l10n-js` regenerates l10n/.js from the JSON catalogue and # fails when the committed file is stale. Nextcloud serves ONLY the JS half # to a browser — raw JSON out of an app directory is a 404 — so a catalogue diff --git a/.gitignore b/.gitignore index 8e213e97f..77a7f7525 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,6 @@ .phpunit.cache/ /node_modules/ -/website/node_modules/ -/website/.docusaurus/ /js/ # Host-side apps dir bind-mounted by docker-compose.yml; App Store installs # land here. Only the placeholder that keeps the directory is tracked. @@ -87,9 +85,9 @@ docker/dolphin/models/ !issues/ !issues/** -/docusaurus/node_modules/ -/docusaurus/build/ -/docusaurus/.docusaurus/ +# Docusaurus writes its build cache here, with absolute paths from the machine +# that built it. It was committed once; never again. +.docusaurus/ # Test screenshots — images generated by browser test commands (test-app, run-test-scenario) # Only images are ignored; markdown reports and scenario files are kept in git. test-results/**/*.png diff --git a/.prettierignore b/.prettierignore index 976cd3fda..13231e070 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,11 +9,9 @@ coverage-vitest/ playwright-report/ test-results/ *.min.* -# This repo carries TWO Docusaurus trees, `docs/` and `docusaurus/`. Both are -# documentation sites with their own conventions (their CSS is space-indented -# by Docusaurus' own scaffolding), and both are built by a separate toolchain. +# `docs/` is the Docusaurus documentation site. It has its own conventions (its +# CSS is space-indented by Docusaurus' own scaffolding) and its own toolchain. docs/ -docusaurus/ # Written by the translation workflow — a formatter here would fight its own # generator on every run. l10n/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 245e05f01..83b7fe008 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -209,12 +209,12 @@ Each release automatically: ## Documentation Release Process -Documentation is built with [Docusaurus](https://docusaurus.io/) and deployed to GitHub Pages. +Documentation is built with [Docusaurus](https://docusaurus.io/) and served by a Cloudflare Worker. -1. Documentation source lives in the `docs/` (or `docusaurus/`) folder on any branch -2. Push or merge to the `documentation` branch triggers the build +1. Documentation source lives in the `docs/` folder +2. A push or merge to `development` triggers the build (`.github/workflows/documentation.yml`) 3. Docusaurus builds the static site -4. The site is deployed to GitHub Pages with a custom domain (e.g., `openregister.app`) +4. The site is published to [keepiq.conduction.nl](https://keepiq.conduction.nl) Each app has its own documentation site — see the app's README for its URL. diff --git a/README.md b/README.md index 0a9f41207..d4817464e 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,10 @@ keepiq/ │ ├── architecture/ # App-specific Architectural Decision Records │ ├── ROADMAP.md # Product roadmap │ └── changes/ # OpenSpec change directories (created on first change) -├── docs/ # Design documentation +├── docs/ # Documentation site (Docusaurus) and design documentation │ ├── ARCHITECTURE.md # Standards, data model, integrations │ ├── FEATURES.md # Competitive analysis, feature matrix │ └── DESIGN-REFERENCES.md # Design patterns, ASCII wireframes -├── docusaurus/ # Documentation site ├── tests/ # Unit and integration tests ├── l10n/ # Translations — 36 locales, .json + generated .js ├── .github/workflows/ # CI/CD pipelines diff --git a/appinfo/info.xml b/appinfo/info.xml index 27a3f3825..aebbf2cb3 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 @@ -94,17 +94,19 @@ Vrij en open source onder de EUPL-1.2-licentie. - + 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/cli/ci.go b/cli/ci.go index a174bdb22..2e7b3b2db 100644 --- a/cli/ci.go +++ b/cli/ci.go @@ -56,16 +56,17 @@ func ciSetup() (c *client.Client, key *rsa.PrivateKey, disc *client.Discovery, b } // fetchDecrypt fetches an application secret by name and decrypts its envelope -// with the application private key (§4.2). Returns the plaintext value. +// with the application private key (§4.2). Returns the plaintext value, which +// the server sends as `ciphertext.key` under the scheme in `encryption.scheme`. func fetchDecrypt(c *client.Client, key *rsa.PrivateKey, name, bearer string) (string, error) { env, err := c.FetchByName(name, bearer) if err != nil { return "", err } - if env.Scheme != "rsa-oaep-sha256-chunked-v1" { - return "", fmt.Errorf("unexpected envelope scheme %q", env.Scheme) + if env.Encryption.Scheme != "rsa-oaep-sha256-chunked-v1" { + return "", fmt.Errorf("unexpected envelope scheme %q", env.Encryption.Scheme) } - return dcrypto.DecryptField(env.Payload.Value, key) + return dcrypto.DecryptField(env.Ciphertext.Key, key) } func cmdCIFetch(args []string) error { diff --git a/cli/ci_test.go b/cli/ci_test.go new file mode 100644 index 000000000..d0a75d5cf --- /dev/null +++ b/cli/ci_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/ConductionNL/keepiq/cli/internal/client" + dcrypto "github.com/ConductionNL/keepiq/cli/internal/crypto" +) + +// machineFixture is testdata/machine_envelope.json: an envelope written by the +// server's real MachineSecretEnvelopeService::serialize() over ciphertext from +// the real EncryptService, plus the throwaway key that decrypts it. PHPUnit +// (tests/Unit/Service/MachineEnvelopeCliFixtureTest.php) fails when serialize() +// stops producing exactly this envelope, so these tests follow the server. +type machineFixture struct { + PrivateKeyPem string `json:"privateKeyPem"` + Plaintext map[string]string `json:"plaintext"` + Envelope json.RawMessage `json:"envelope"` +} + +func loadMachineFixture(t *testing.T) machineFixture { + t.Helper() + raw, err := os.ReadFile("testdata/machine_envelope.json") + if err != nil { + t.Fatal(err) + } + var f machineFixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + if len(f.Envelope) == 0 || f.PrivateKeyPem == "" || f.Plaintext["key"] == "" { + t.Fatal("testdata/machine_envelope.json is missing envelope, privateKeyPem or plaintext.key") + } + return f +} + +// stubKeepiq serves discovery, the token endpoint and the by-name read, the +// last one answering with the server's envelope bytes and lease headers. +func stubKeepiq(t *testing.T, envelope []byte) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/apps/keepiq/api/v1/app/.well-known/doriath", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"apiVersion":1,"tokenEndpoint":"/apps/keepiq/api/v1/app/token","assertion":{"alg":"RS256","audience":"doriath"},"lease":{"supported":true}}`)) + }) + mux.HandleFunc("/apps/keepiq/api/v1/app/token", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"access_token":"tok","token_type":"Bearer"}`)) + }) + mux.HandleFunc("/apps/keepiq/api/v1/app/secrets/by-name/", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("by-name read without the bearer, got %q", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Doriath-Lease-Id", "lease-7") + w.Header().Set("Doriath-Lease-Expires", "2026-10-01T00:00:00+00:00") + _, _ = w.Write(envelope) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestFetchDecryptRealServerEnvelope decrypts the envelope the server really +// sends (keepiq#793): the scheme sits under encryption.scheme and the value +// under ciphertext.key. +func TestFetchDecryptRealServerEnvelope(t *testing.T) { + f := loadMachineFixture(t) + srv := stubKeepiq(t, f.Envelope) + key, err := dcrypto.ParsePrivateKey(f.PrivateKeyPem) + if err != nil { + t.Fatal(err) + } + + c := client.New(srv.URL) + got, err := fetchDecrypt(c, key, "ci-fixture-db-password", "tok") + if err != nil { + t.Fatalf("fetchDecrypt: %v", err) + } + if got != f.Plaintext["key"] { + t.Fatalf("value = %q, want %q", got, f.Plaintext["key"]) + } + if c.LeaseID() != "lease-7" { + t.Fatalf("lease id = %q, want lease-7", c.LeaseID()) + } +} + +// TestFetchDecryptRefusesAnUnknownScheme keeps the scheme check: an envelope +// naming another scheme is refused before any decryption. +func TestFetchDecryptRefusesAnUnknownScheme(t *testing.T) { + f := loadMachineFixture(t) + var env map[string]any + if err := json.Unmarshal(f.Envelope, &env); err != nil { + t.Fatal(err) + } + env["encryption"].(map[string]any)["scheme"] = "rsa-oaep-sha1-v0" + body, _ := json.Marshal(env) + srv := stubKeepiq(t, body) + key, err := dcrypto.ParsePrivateKey(f.PrivateKeyPem) + if err != nil { + t.Fatal(err) + } + + _, err = fetchDecrypt(client.New(srv.URL), key, "ci-fixture-db-password", "tok") + if err == nil || !strings.Contains(err.Error(), `unexpected envelope scheme "rsa-oaep-sha1-v0"`) { + t.Fatalf("want an unexpected scheme error, got %v", err) + } +} + +// TestCIFetchPrintsValueAndLease runs `keepiq ci fetch --output json` +// end to end against the stub: the decrypted value goes to stdout and the +// lease line to stderr. +func TestCIFetchPrintsValueAndLease(t *testing.T) { + f := loadMachineFixture(t) + srv := stubKeepiq(t, f.Envelope) + t.Setenv("KEEPIQ_URL", srv.URL) + t.Setenv("KEEPIQ_APP_ID", "app-cli-fixture") + t.Setenv("KEEPIQ_APP_KEY", f.PrivateKeyPem) + t.Setenv("KEEPIQ_APP_KEY_FILE", "") + + stdout, stderr, err := captureOutput(t, func() error { + return cmdCIFetch([]string{"ci-fixture-db-password", "--output", "json"}) + }) + if err != nil { + t.Fatalf("ci fetch: %v", err) + } + var out map[string]string + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("stdout is not JSON: %q", stdout) + } + if out["name"] != "ci-fixture-db-password" || out["value"] != f.Plaintext["key"] { + t.Fatalf("stdout = %v, want name ci-fixture-db-password and the decrypted value", out) + } + if !strings.Contains(stderr, "lease lease-7 expires 2026-10-01T00:00:00+00:00") { + t.Fatalf("stderr has no lease line: %q", stderr) + } +} + +// captureOutput runs fn with os.Stdout and os.Stderr redirected to pipes. +func captureOutput(t *testing.T, fn func() error) (string, string, error) { + t.Helper() + oldOut, oldErr := os.Stdout, os.Stderr + outR, outW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + errR, errW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout, os.Stderr = outW, errW + var outBuf, errBuf bytes.Buffer + done := make(chan struct{}, 2) + go func() { _, _ = io.Copy(&outBuf, outR); done <- struct{}{} }() + go func() { _, _ = io.Copy(&errBuf, errR); done <- struct{}{} }() + + runErr := fn() + + os.Stdout, os.Stderr = oldOut, oldErr + _ = outW.Close() + _ = errW.Close() + <-done + <-done + return outBuf.String(), errBuf.String(), runErr +} diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index a8d3ba784..4fe0c4b94 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -197,13 +197,34 @@ func (c *Client) MachineToken(applicationID string, key *rsa.PrivateKey, disc *D return tok.AccessToken, nil } -// MachineEnvelope is the doriath-machine-secret-v1 envelope (CI fetch, §4.2). +// MachineEnvelope is the doriath-machine-secret-v1 envelope (CI fetch, §4.2), +// in the shape lib/Service/MachineSecretEnvelopeService.php serialize() +// writes: metadata under `secret`, the scheme under `encryption.scheme`, and +// the base64 ciphertext under `ciphertext.key`, `ciphertext.login` and +// `ciphertext.additionalFields`. cli/testdata/machine_envelope.json is that +// serializer's real output, guarded by a PHPUnit test (keepiq#793). type MachineEnvelope struct { - Format string `json:"format"` - Scheme string `json:"scheme"` - Payload struct { - Value string `json:"value"` - } `json:"payload"` + Format string `json:"format"` + Secret struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + FolderPath string `json:"folderPath"` + Type string `json:"type"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + KeyUpdatedAt string `json:"keyUpdatedAt"` + } `json:"secret"` + Encryption struct { + SuiteID string `json:"suiteId"` + CertificateFingerprint string `json:"certificateFingerprint"` + Scheme string `json:"scheme"` + } `json:"encryption"` + Ciphertext struct { + Key string `json:"key"` + Login string `json:"login"` + AdditionalFields string `json:"additionalFields"` + } `json:"ciphertext"` } // FetchByName fetches an application secret envelope by name with the bearer diff --git a/cli/internal/client/client_test.go b/cli/internal/client/client_test.go index f2a4f3074..11744e113 100644 --- a/cli/internal/client/client_test.go +++ b/cli/internal/client/client_test.go @@ -1,17 +1,38 @@ package client import ( + "encoding/json" "errors" "net/http" "net/http/httptest" + "os" "testing" ) +// serverEnvelope returns the envelope the server's real +// MachineSecretEnvelopeService::serialize() writes (cli/testdata, guarded by a +// PHPUnit test), so this test cannot drift back to a shape only the CLI knows. +func serverEnvelope(t *testing.T) []byte { + t.Helper() + raw, err := os.ReadFile("../../testdata/machine_envelope.json") + if err != nil { + t.Fatal(err) + } + var f struct { + Envelope json.RawMessage `json:"envelope"` + } + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + return f.Envelope +} + // TestFetchByNameConditional verifies the ETag poll loop: the first fetch -// captures the ETag and decodes the envelope; an unchanged re-fetch sends -// If-None-Match and is answered 304 → ErrNotModified (§4.2). +// captures the ETag and decodes the server's envelope; an unchanged re-fetch +// sends If-None-Match and is answered 304 → ErrNotModified (§4.2). func TestFetchByNameConditional(t *testing.T) { const etag = `"v1-abc"` + body := serverEnvelope(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer tok" { t.Errorf("missing bearer, got %q", r.Header.Get("Authorization")) @@ -25,7 +46,7 @@ func TestFetchByNameConditional(t *testing.T) { w.Header().Set("Doriath-Lease-Id", "lease-9") w.Header().Set("Doriath-Lease-Expires", "2026-01-01T00:00:00Z") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"format":"doriath-machine-secret-v1","scheme":"rsa-oaep-sha256-chunked-v1","payload":{"value":"QUJD"}}`)) + _, _ = w.Write(body) })) defer srv.Close() @@ -35,8 +56,17 @@ func TestFetchByNameConditional(t *testing.T) { if err != nil { t.Fatalf("first fetch: %v", err) } - if env.Payload.Value != "QUJD" { - t.Fatalf("payload = %q", env.Payload.Value) + if env.Format != "doriath-machine-secret-v1" { + t.Fatalf("format = %q", env.Format) + } + if env.Encryption.Scheme != "rsa-oaep-sha256-chunked-v1" { + t.Fatalf("encryption.scheme = %q", env.Encryption.Scheme) + } + if env.Ciphertext.Key == "" || env.Ciphertext.Login == "" || env.Ciphertext.AdditionalFields == "" { + t.Fatalf("ciphertext fields not decoded: %+v", env.Ciphertext) + } + if env.Secret.Name != "ci-fixture-db-password" { + t.Fatalf("secret.name = %q", env.Secret.Name) } if c.LeaseID() != "lease-9" { t.Fatalf("lease id = %q", c.LeaseID()) diff --git a/cli/testdata/machine_envelope.json b/cli/testdata/machine_envelope.json new file mode 100644 index 000000000..2f35e49aa --- /dev/null +++ b/cli/testdata/machine_envelope.json @@ -0,0 +1,33 @@ +{ + "_comment": "TEST ONLY. A throwaway RSA-4096 key and the envelope MachineSecretEnvelopeService::serialize() writes for a secret encrypted to it by EncryptService. Written by tests/Unit/Service/MachineEnvelopeCliFixtureTest.php with KEEPIQ_WRITE_CLI_FIXTURE=1. The key protects nothing.", + "privateKeyPem": "-----BEGIN PRIVATE KEY-----\nMIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDVyuXY1p6uymlh\nRrMB5RS0JyEg+57h00KK6UyKHRHyz8v7+WVhzYHK9iQve+C0nVLYYRUYEYu6oRm9\njEbzw+sr6vGP7cOlRraPQbuD/WZfLMOP6he6UCldG9vy8z2teA3rwuxT3vQxxsZT\n4LltDltU0Q47KMshnZe8IcZxbptiOVHbV491sATrclGzrQf3byCZqrwS7FeBSIMn\n27cxUk6U14CbZxykdw6ZnWkzCMlQESZ9NuQvft6Y9D9MD10LbUSe+j7iu++GGsaj\n/Z35YgZV1kLlwVUVnrAq8bJL4HA/N7bOJoUdFdG0sZpJl06wKulbk9QgX+jQGNqL\nYH9US7IR5efz4yLDVijgAePeOoYIw9l5/eUTJQEa/9eC2vN4XHFDkLs5TRmHlQMX\n5dKKOc4z9sNxvkV5uJ8GyYSOH69HE5xn+EUgD+XFeCqwjkDXcEjBvAibzt1t3+KY\nl56lNRb4NzpWgAZ21uDZsNrOSO/d3385uaAKWQ4tyOYw/PsItYlHYb/dkwRRvSxS\nh5gyt1EZ9tpI2MxE/9r6b971UtBIvi7rgaS3uVIBsb4JdUMrEX04F7mQpHi7X9x0\nkTEcvTlaZhoc7dooCs2YAeGkcbDHNas+QObVnFCpLlpYxwTBEFBa3w/v9vbMbt7+\ncauGodLYFtB/WxHcMm5TmpMO3YTpGQIDAQABAoICAASQviLIxn+2C9ULdPQj57LH\nKSJyrRxmbgPYYo2KfGEihdkeJMeXY1+prPEkpJJGz8eWHySx7zlvikStc7Bt7R41\neK4ZpjVn0qsxfKcfaKm8o3p1xcbxQkjeOO6xug3zw9xWf8R0vOM5Ou/8mwkUpuej\nRToHL3tKybw3Pm0arw/EaJaWApbBSQGcIdmD5ukmQtx1NGn1CRNWMJLP0tQnTU1J\nYtIuvAJZFoM6RM5VsDLZAujmnK3U+Dzs7lMZ+uMtFKuObXxbBgznknlbB54VNUxs\n9GXCoKYDCwEHw0ZyhwMalflGAk6yMhW5OzG6HPCPIgeWGxAL7EVRWo1iZKWwrh9A\ntTqBO/CnEXnJOv4juSdm2QHQlsacdedTsUEhrWS1VZgZcYtTeASyXMc7IbGNv7kt\nOwwf3+DbYV293xxiYZF9JJcGrndvfQk98ePkWiJ7Rh7ic0Km9d8HW4zO8O0Zvri3\nD7thobfcyOfY2mkdQcKyUsVveIc0Om+5YGZyNMUvfy8xXQ1l5YK8j6P4It6rEmW7\nP9iPwg//+t3unL0AZKtwcD4v5CScCHBe64lpnHcLEKsHQhVdcy1ArlT++qjYUdyo\n8ngbqdJGDz/PcDwwtC9uwoOQ86GU0b4G4s+lXJ41HvUcQg7d3aeMGz3RsyppMCNs\nWetZoXveF1egqAnOhH/5AoIBAQD6hRVYiSIwkgQ2bAalYd5A9z0Xg5DFLLjVdkF5\nOIRB6N0Ol0Yc7nx7iSu82JtibB6knzsqGKMs1v27F1AX1xzU8XiwFedWJec/4Pyl\nj9VVqiauJZyCHyex/xo+2b/MfKXEvKNMHbBAB6+VC6BSDZeQujtjt5Nq4qJiFLlc\nSYj4Bg/ilf/o1LpuansVypsci/7S7ZzdFW6COoj8mvXWy89k4G+Sxd5Va3tohV7S\nZUugZmpb+T854vpM711LBFFt7pKGIe91WoiGdwK4n5v8rOgg7RX9IQieCOdsoRDF\npYbR6oPcV27Opr8s0/1ZI7RGnRx2IBg32Bzdrq3fOd7kTNo1AoIBAQDaeCQPC8s3\nrNX+uhV2W0tYdmAYTVnnvQIdGme3bMZsPmufkI58qH91w+XGNkN0GNYhKzDUvwrQ\nhPjTAP8yv5XyjrvUwwBkyYVSsi5sNTM8QjNqa0F6Mfl+LV0l36/H8B64SR7JP0yU\nVG4bwxzUm9umB4eqhDDqZP2rwdwfyO7RG+wfuYDutQI2c3QoknPamQ7EJoKIRHbQ\ng9T2wXZEvlqZHPsKKDNyhoIDXKQ08+MA09ZAtyY9lBIidYTJqvOOdDdmqxDRr6bb\nCg3MON0GL9bcC+5gRb+9jtmyNHlFdlrCnt6P/106JM53xPVmRn+E6D6xi/bnzl9I\ndFW0F7U46U/VAoIBACUHfNM8WeBiBNtidk0FvUtPACm0mbZ7WMq8kwru7qWrXNlm\nRMfL/HxqFxvvzG/s2U6t9pnPUHc1RAXeeUk805cqJhOo+SvDqH3JrK5wJzFlWBhP\nmOWm40AtG90EZJQ+LGZ7F4wNq1qbiDl4oXbfaXLMBDFGFjUrUKM8uvVILUWz568v\nSsdIglIFzTG4Y9sBWmYlFxWasF3xdwQXh5T7RSl8/yDrvGRV4cTeSdjBfj80RC5a\nFh1hTmwGyXxoI/0i0WegJnvrKNjvSGivtufPSvRq9uAWfOaXHFXzLL56LAZlKp5s\nEgXbodBneUAuxdvWJznSnqHKgOHHiw2+yRJnxa0CggEAHByTf0AkQSDDVIi7VESB\nGHv694WUiQZALGCGcgrUKX+Kt7iRLSc9Z3jyqyO2YE0F2LTHDskaaiSCJByfufb/\nI3wMXV9OQKnRtwdog9IAqsNV/F6ayU9+7CaIMuRqqQ6T1WDLTlgP0ZJfrMDZh94a\nUG3tnhpudOrT3XMPkSN43315T8e9+HlgNIN8se/OapWBWeoep8wvgbtE/w972XKp\nE8Sv5OmxXQ5D0ozsMEglsKiRjDhE+m2AO+DlWlHeDzmTG2ntux2gGdVcWygCANbN\nDfmXDdpLxueHya+I31BQc3Sf7OWOKHot+ylj7mop5H9B+fSpq8BZcGhP0uGuFwmm\nlQKCAQB61EqL40ACKnYQGB6iYkNijBRxNyJ8/1udcTVMuBnUpNX/XbeBsTqQhJ+K\n5SyQ9ywZkxQ4nGfcGMg1CJBRpmHqP471hhXkplHFcmNeEs1GC+E7/HkB/ppWZeuz\n6qFThXinkAkTbj2bDGpRVQokk4ZxlXQSYgyh2Iyal7QhmkrXJPU61b9QbrXOSdU6\n0HDJRpkDdqqNI5MkzbJmucv2d56lZX+b4OVcC4tJILfLNQuB/WjN/vQVAC8rwNC+\n0osiiV6pulcZ67QzrbkRPnk2UCI1MXm6iXVOb8AztwbGw02jj46YmWeKegGGrl+x\nJ++MuehpeiadZ6EhpqU6UOUc9z4e\n-----END PRIVATE KEY-----\n", + "certificatePem": "-----BEGIN CERTIFICATE-----\nMIIFpjCCA46gAwIBAgIBADANBgkqhkiG9w0BAQsFADBsMSUwIwYDVQQDDBxrZWVw\naXEtY2xpLWZpeHR1cmUtdGVzdC1vbmx5MQswCQYDVQQGEwJBVTETMBEGA1UECAwK\nU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMB4X\nDTI2MDkyODA3NTUwNVoXDTM2MDkyNTA3NTUwNVowbDElMCMGA1UEAwwca2VlcGlx\nLWNsaS1maXh0dXJlLXRlc3Qtb25seTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNv\nbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCAiIw\nDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANXK5djWnq7KaWFGswHlFLQnISD7\nnuHTQorpTIodEfLPy/v5ZWHNgcr2JC974LSdUthhFRgRi7qhGb2MRvPD6yvq8Y/t\nw6VGto9Bu4P9Zl8sw4/qF7pQKV0b2/LzPa14DevC7FPe9DHGxlPguW0OW1TRDjso\nyyGdl7whxnFum2I5UdtXj3WwBOtyUbOtB/dvIJmqvBLsV4FIgyfbtzFSTpTXgJtn\nHKR3DpmdaTMIyVARJn025C9+3pj0P0wPXQttRJ76PuK774YaxqP9nfliBlXWQuXB\nVRWesCrxskvgcD83ts4mhR0V0bSxmkmXTrAq6VuT1CBf6NAY2otgf1RLshHl5/Pj\nIsNWKOAB4946hgjD2Xn95RMlARr/14La83hccUOQuzlNGYeVAxfl0oo5zjP2w3G+\nRXm4nwbJhI4fr0cTnGf4RSAP5cV4KrCOQNdwSMG8CJvO3W3f4piXnqU1Fvg3OlaA\nBnbW4Nmw2s5I793ffzm5oApZDi3I5jD8+wi1iUdhv92TBFG9LFKHmDK3URn22kjY\nzET/2vpv3vVS0Ei+LuuBpLe5UgGxvgl1QysRfTgXuZCkeLtf3HSRMRy9OVpmGhzt\n2igKzZgB4aRxsMc1qz5A5tWcUKkuWljHBMEQUFrfD+/29sxu3v5xq4ah0tgW0H9b\nEdwyblOakw7dhOkZAgMBAAGjUzBRMB0GA1UdDgQWBBT+P2WlRZZ0dorh6kaOluqX\nn54F9jAfBgNVHSMEGDAWgBT+P2WlRZZ0dorh6kaOluqXn54F9jAPBgNVHRMBAf8E\nBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQAUGqzCZgQDRv8OA1ZYj+ti6yDB/YE7\nw9yMgpPg0qU4v7gku1JzQpDoh8TD48NB8FzxExhq3mNuhsjuvHsRg2HZdK/6wi9J\nyVmiflKdwSNaUwd3tp+lCNL4Y6osM7BS+meHlroNAIMleW4kzkFmfB9mLLAT6A2i\n03AUzPBnaUbHMlDX9U2atWmLrk8KlILpI0DoJQkB18TPXsia9jWvjsT/vypiY/lW\n2PqXQG6ky1MyZEaC+9GO8dpSZDHvKLW8/vgV1+6nIOitULXKhk/0+xqBDkHJtFdo\nFRdfw4xEXzt2jkqjkTc8x2ww9av92E40DclZoE7l5/RZmTPkQGl5mgkDwVK1FC3k\nxgUrYbZrlsNMtu1v9OaaZ93OzGNL/pZ6hShPeYbL2PEzHAWUjaHUT4/gwc1TS13t\nbv3z/2h86bZDPlOI7htBuyp63iexil8vud88HYp4ddTL+JW+hff+Cf1pRRi8NMnb\nBLyqeTx19TPG8Dygaz/Y/udYkUa+atxJEm685421a55vP0cPT+8Ptcsx+Dx2m90y\nvB5NTA7sO14TgPW5pbUSlktcRqoL0KfZyyyKHH0BKY5Q+xKs1KRQFrznpb1iKIT7\nPq/8tQGC4ryf1jIeARbkrJYZxBqW+8Zui4SO3FsiL+4jbD11V9pb4R/7fxIoQdCM\nxgAfteY3MD5CYg==\n-----END CERTIFICATE-----\n", + "plaintext": { + "key": "ci-fixture-db-password", + "login": "ci-deployer", + "additionalFields": "{\"host\":\"db.internal.test\",\"port\":\"5432\"}" + }, + "envelope": { + "format": "doriath-machine-secret-v1", + "secret": { + "id": "sec-cli-fixture", + "name": "ci-fixture-db-password", + "url": "https://db.internal.test", + "folderPath": "ci/database", + "type": "9c491896-3d6b-5a1a-9b0e-e89dfeb6617c", + "createdAt": "2026-09-28T09:00:00+00:00", + "updatedAt": "2026-09-28T09:00:00+00:00", + "keyUpdatedAt": "2026-09-28T09:00:00+00:00" + }, + "encryption": { + "suiteId": "suite-cli-fixture", + "certificateFingerprint": "sha256:81394845ca3ff63930b78428f345690b1f96a867bd644e9823e06624992457c5", + "scheme": "rsa-oaep-sha256-chunked-v1" + }, + "ciphertext": { + "key": "AAAAAZh7P3W3TG9PqU/dr5hPbs5oysQzT9QNiQdG1w8CaFbfJdSlDWY+wYG5C0hD5Q5m1SCb2ayCt9FccxOzukQIz03tO/2VZPp+IVxq4wq29B9vQuJx9NQEOvobu8ePMlvpgDX3uyTZn6R6ABnYvVFMFwtenEenL7ray2Vts5iMB/ikMD+Iv5Clljkj/TzFscWxUvT7jExLhMb32M5GL9ITFNZH5uABQJiAaUGmL4ALzcsCqc7KhdfZhUQXICB/92vhlWeHeDHnKzQvCqLh1ZPHfnvW3CJ9VwYz7jquZYVb/qG5Fd/BoSWLgxTSbjUncIkEbI6TFsD9kPdKQxbuYu8L7RDaJgnffGt6wXFF8yWySB7f8ZTObKrAk/OxW+rbmV+JvVTy8BFLFe73RxF/F7VBvRlJGccE1mL22qILAaUbXtm2L4W/al8yEsB5JtZHyzkZWoX9vKN75l/EmmJjtobHtMQCH9wJjfLROwGiraoWNPvOVauwCZWVUwmGmCiZPtVCiFVskmfYuvMy524yJd3kPLzqfCRwAia8Q89CC2bcx95Lc+WOID/QPMwfZuUA5ig+K6iP4QaDaHJ2Px6B02MmbSZHIvWwHwq6rOhBXjMdFKm3N0t56MhyKb5meDEgi0iYKNhIQLGzeHzn6UbFRXnxY5duGOx8We3t4jfcXDgk6x/Y", + "login": "AAAAAUkzxsmg+Piid/W6XVtkiIdjxl9TBYXIKMZaVFEHQGIGTO1oHJuoErTb1JIfRP53Z9LJM1IgfuUn5XBVaTd+zqc3AeNU5OvBWh+XMPZCWIKq8+RydiynHw4s7EV0D+jgrNAufjdgS2bqSJn+XGpV/AuX9Y8jzvj2aLEwANLlFQjOaGUbddzIEsFKeQOEL1egnHjEvjYRo7pzaMc1GRyDhOFRegxPBFd6xfmkIXLRBIWBk1aqo2Ce0CoS/Ur4eG/XWszSzMSmFxVYK5IeG6+fsI5CaJghkPTn6BnDFB46HuTyNJd8LElvGULaM682myheHAXu5SdTEKnMaAhvXvrMPo9aiGz3HNMKkZvCoP0BOpoZ1XwPHLtDFxlzjYFq8o+V3SaMxtnz6WotwuQ9XXYE/etpgOZ5EehHSnlEBCQ4E7MwGjJIoHEkKD4AaOMJQPUzKRwevBuCdpFSofx3FjLTReLFDPl2HJouUChxGr6JJPsaYT1sF6yk6ogBsAYFaXs8byEjMjziE+MSDtBtYKqwWACkit0UAElhD7Q61NjrDk3yZUooum07yLmy2s1oI6R0IWDpbpNS+0obvYNhBS/b8WrVK+WBw1twqaAY4jM6tRt/W+Xep57hqlNd9mL4LJqZ0yszp43ZLNJYi6UzUm+UxIttuqN5+V6ho/0YTmT4AmbG", + "additionalFields": "AAAAAW6P+LNbU6AgL7DE4dX9HafqcrNuLMj5Ba9mzt9C7/Fk305Ja2d1D6GRxmISbAtX2V1RrCKGMB/4IwMp5lBYhgCJkJzTHikkc4hr63ZHGcE1LwclbWm25jgzUOW+ss5TBzp/hb/cvIu2khXjmrUPxPnR5sIIY5LrnndvAm7Y0lfgQpn8VG4MMvYwU1ADAMuTJ3qtC50GEivrgG9v/uMtKtGysM0Cg4IPJglll5vl/fGJ09P+Rz96rLMewRGo+O8oXof5hY2133LdXdljVU/8zU5VMxizY94+kUNJFkKLGvdy+GYxn+Z1Yf8HtVuM8s6k+KPRQIldiDyve/U8MsoWCyOxBCfBCocoZpNG8+8BuMe6WqNzP/rKGi64434OPzYadc2Jk6rEdWgXmujSPrwQg71923cborIi1TmvgMgxV1gi6uU9NgddrdEyRFIO+3Si9EoytgsgctqkCcnedzj5CaHRn7x/D9m1bwfOjq9aF+EpfPZymB3dlVX2fBtnaezknrHvN3E2KsEWtDl6rNxyBi5dmEOqbbCZKekfYKA+8PYiuOU2aSMPbNhTUnpBB/R8BGb4czzK03jelnKhDppnLX5qQc9C4k5ZTEyXY0xZrr19UXkE3QWYTq83A1Sr7kzA7/hOFYV1l/OwQluZdxfM/dwjebagbC9s43bzeedR7zWt" + } + } +} 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..e44cb03b9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,27 +4,28 @@ "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", - "version": "0.18.0", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "phpstan/phpstan": "2.1.22", + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", "phpunit/phpunit": "^11.5" }, "type": "library", @@ -55,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.18.0" + "source": "https://github.com/brick/math/tree/1.0.0" }, "funding": [ { @@ -63,7 +64,7 @@ "type": "github" } ], - "time": "2026-06-14T18:21:03+00:00" + "time": "2026-09-12T10:28:18+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -420,20 +421,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -492,26 +493,26 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "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 +593,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 +605,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 +680,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 +754,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 +766,7 @@ "type": "patreon" } ], - "time": "2026-08-30T13:53:36+00:00" + "time": "2026-09-12T18:50:35+00:00" } ], "packages-dev": [ @@ -1278,16 +1279,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 +1327,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 +2637,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 +2684,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 +3936,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 +3996,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 +7648,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 +7709,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 +7729,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 +7896,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", @@ -8343,16 +8588,16 @@ }, { "name": "twig/twig", - "version": "v3.28.0", + "version": "v3.29.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" + "reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", - "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/45a3c6e9224c3377a39c7b150bb29d5d97d2c75d", + "reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d", "shasum": "" }, "require": { @@ -8407,7 +8652,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.28.0" + "source": "https://github.com/twigphp/Twig/tree/v3.29.0" }, "funding": [ { @@ -8419,7 +8664,7 @@ "type": "tidelift" } ], - "time": "2026-07-03T20:44:34+00:00" + "time": "2026-09-18T09:10:14+00:00" }, { "name": "vimeo/psalm", diff --git a/docs/.docusaurus/DONT-EDIT-THIS-FOLDER b/docs/.docusaurus/DONT-EDIT-THIS-FOLDER deleted file mode 100644 index 6c06ae873..000000000 --- a/docs/.docusaurus/DONT-EDIT-THIS-FOLDER +++ /dev/null @@ -1,5 +0,0 @@ -This folder stores temp files that Docusaurus' client bundler accesses. - -DO NOT hand-modify files in this folder because they will be overwritten in the -next build. You can clear all build artifacts (including this folder) with the -`docusaurus clear` command. diff --git a/docs/.docusaurus/client-manifest.json b/docs/.docusaurus/client-manifest.json deleted file mode 100644 index 110970272..000000000 --- a/docs/.docusaurus/client-manifest.json +++ /dev/null @@ -1,1083 +0,0 @@ -{ - "entrypoints": [ - "main" - ], - "origins": { - "165": [ - 2076, - 7928, - 9557, - 165 - ], - "253": [ - 2076, - 253 - ], - "384": [ - 697, - 2076, - 384 - ], - "399": [ - 399 - ], - "536": [ - 536 - ], - "685": [ - 2076, - 685 - ], - "697": [ - 384, - 1301, - 1513, - 2076, - 7563, - 8363, - 697 - ], - "743": [ - 1513, - 2076, - 743 - ], - "786": [ - 2076, - 786 - ], - "931": [ - 1869, - 2076, - 2634, - 8401, - 931 - ], - "1301": [ - 697, - 1513, - 2076, - 7563, - 8363, - 1301 - ], - "1513": [ - 697, - 743, - 1301, - 2076, - 8363, - 1513 - ], - "1795": [ - 2076, - 1795 - ], - "1844": [ - 2076, - 1844 - ], - "2014": [ - 2014 - ], - "2130": [ - 2130 - ], - "2237": [ - 1869, - 2237 - ], - "2438": [ - 2076, - 4787, - 2438 - ], - "2555": [ - 2555 - ], - "2665": [ - 2076, - 2665 - ], - "3049": [ - 3049 - ], - "3088": [ - 2076, - 3088 - ], - "3436": [ - 3436 - ], - "3593": [ - 3593 - ], - "3674": [ - 3674 - ], - "3723": [ - 2076, - 3723 - ], - "3872": [ - 3872 - ], - "3923": [ - 3923 - ], - "4045": [ - 4045 - ], - "4219": [ - 2076, - 4219 - ], - "4728": [ - 2076, - 4728 - ], - "4737": [ - 4737 - ], - "4787": [ - 2076, - 2438, - 8320, - 4787 - ], - "5438": [ - 2076, - 5438 - ], - "5692": [ - 5692 - ], - "6246": [ - 2076, - 6246 - ], - "6288": [ - 6288 - ], - "6402": [ - 6402 - ], - "6678": [ - 2076, - 6678 - ], - "6735": [ - 6735 - ], - "7005": [ - 2076, - 7005 - ], - "7059": [ - 2076, - 7059 - ], - "7180": [ - 7180 - ], - "7563": [ - 697, - 1301, - 7563 - ], - "7928": [ - 165, - 7928 - ], - "8007": [ - 1869, - 2076, - 2634, - 8401, - 8007 - ], - "8119": [ - 8119 - ], - "8320": [ - 2076, - 4787, - 8320 - ], - "8363": [ - 697, - 1301, - 1513, - 2076, - 8363 - ], - "8646": [ - 8646 - ], - "9261": [ - 9261 - ], - "9299": [ - 2076, - 9299 - ], - "9301": [ - 2076, - 9301 - ], - "9557": [ - 165, - 2076, - 9557 - ], - "17896441": [ - 1869, - 2076, - 8007, - 8401 - ], - "main": [ - 1869, - 5354, - 8792 - ], - "runtime~main": [ - 1869, - 8792, - 5354 - ], - "11b43341": [ - 2256 - ], - "14eb3368": [ - 1869, - 6969 - ], - "1e9767c5": [ - 2076, - 9348 - ], - "2e5b4b03": [ - 2076, - 5680 - ], - "3b3e214a": [ - 2076, - 9366 - ], - "4081bc5c": [ - 1869, - 931 - ], - "5e95c892": [ - 9647 - ], - "6f7f7b1d": [ - 2076, - 8336 - ], - "8cb5fad5": [ - 4673 - ], - "922e3df9": [ - 2076, - 9011 - ], - "a7456010": [ - 1235 - ], - "a7bd4aaa": [ - 7098 - ], - "a94703ab": [ - 1869, - 9048 - ], - "aba21aa0": [ - 5742 - ], - "af516ec7": [ - 1645 - ], - "af75b968": [ - 6687 - ], - "b1abb4d6": [ - 9705 - ], - "c4f5d8e4": [ - 1869, - 8007, - 2634 - ], - "ec6d3eb8": [ - 2076, - 5359 - ], - "f622ed5e": [ - 9062 - ], - "styles": [ - 931, - 2076, - 2237, - 2634, - 5354, - 6969, - 8007, - 8401, - 8792, - 9048, - 1869 - ], - "common": [ - 165, - 253, - 384, - 685, - 697, - 743, - 786, - 1301, - 1513, - 1795, - 1844, - 1869, - 2438, - 2665, - 3088, - 3723, - 4219, - 4728, - 4787, - 5359, - 5438, - 5680, - 6246, - 6678, - 7005, - 7059, - 8007, - 8320, - 8336, - 8363, - 8401, - 9011, - 9299, - 9301, - 9348, - 9366, - 9557, - 2076 - ] - }, - "assets": { - "165": { - "js": [ - { - "file": "assets/js/165.6fcb797c.js", - "hash": "621b556d1a33b571", - "publicPath": "/assets/js/165.6fcb797c.js" - } - ] - }, - "253": { - "js": [ - { - "file": "assets/js/253.d8f5fb6d.js", - "hash": "136755edd59cb986", - "publicPath": "/assets/js/253.d8f5fb6d.js" - } - ] - }, - "384": { - "js": [ - { - "file": "assets/js/384.3370efe6.js", - "hash": "eabb08c6bd3831e1", - "publicPath": "/assets/js/384.3370efe6.js" - } - ] - }, - "399": { - "js": [ - { - "file": "assets/js/399.c76183a6.js", - "hash": "e63bc93deb38727b", - "publicPath": "/assets/js/399.c76183a6.js" - } - ] - }, - "536": { - "js": [ - { - "file": "assets/js/536.d9657a10.js", - "hash": "98593ac9bcd6b33a", - "publicPath": "/assets/js/536.d9657a10.js" - } - ] - }, - "685": { - "js": [ - { - "file": "assets/js/685.51378b69.js", - "hash": "d73bfd585be9b88f", - "publicPath": "/assets/js/685.51378b69.js" - } - ] - }, - "697": { - "js": [ - { - "file": "assets/js/697.e1117f89.js", - "hash": "4dd4c23597a4c45a", - "publicPath": "/assets/js/697.e1117f89.js" - } - ] - }, - "743": { - "js": [ - { - "file": "assets/js/743.b8acf669.js", - "hash": "1e6022ca55ddb34d", - "publicPath": "/assets/js/743.b8acf669.js" - } - ] - }, - "786": { - "js": [ - { - "file": "assets/js/786.6a9c60f0.js", - "hash": "777531f0772f2533", - "publicPath": "/assets/js/786.6a9c60f0.js" - } - ] - }, - "931": { - "js": [ - { - "file": "assets/js/4081bc5c.cbc1d1e5.js", - "hash": "854ffd837f3fe460", - "publicPath": "/assets/js/4081bc5c.cbc1d1e5.js" - }, - { - "file": "assets/js/8007.cfe7f045.js", - "hash": "a6bee805f6809559", - "publicPath": "/assets/js/8007.cfe7f045.js" - } - ] - }, - "1235": { - "js": [ - { - "file": "assets/js/a7456010.be9c64ae.js", - "hash": "1cb26843e958476b", - "publicPath": "/assets/js/a7456010.be9c64ae.js" - } - ] - }, - "1301": { - "js": [ - { - "file": "assets/js/1301.9989ee43.js", - "hash": "7756f3e81d7a135b", - "publicPath": "/assets/js/1301.9989ee43.js" - } - ] - }, - "1513": { - "js": [ - { - "file": "assets/js/1513.b9d94836.js", - "hash": "e82f4b46ad243e67", - "publicPath": "/assets/js/1513.b9d94836.js" - } - ] - }, - "1645": { - "js": [ - { - "file": "assets/js/af516ec7.8a98155b.js", - "hash": "fcdc0ff529e4aa2d", - "publicPath": "/assets/js/af516ec7.8a98155b.js" - } - ] - }, - "1795": { - "js": [ - { - "file": "assets/js/1795.d6fd3594.js", - "hash": "4037944b3b73b113", - "publicPath": "/assets/js/1795.d6fd3594.js" - } - ] - }, - "1844": { - "js": [ - { - "file": "assets/js/1844.905a2cb5.js", - "hash": "7289709cf5eec3a3", - "publicPath": "/assets/js/1844.905a2cb5.js" - } - ] - }, - "1869": { - "css": [ - { - "file": "assets/css/styles.28363669.css", - "hash": "3d13fa6d9c5d4bdf", - "publicPath": "/assets/css/styles.28363669.css" - } - ] - }, - "2014": { - "js": [ - { - "file": "assets/js/2014.8390c3fa.js", - "hash": "c242451895fccc17", - "publicPath": "/assets/js/2014.8390c3fa.js" - } - ] - }, - "2076": { - "js": [ - { - "file": "assets/js/common.f3191396.js", - "hash": "cb604475a9bd8914", - "publicPath": "/assets/js/common.f3191396.js" - } - ] - }, - "2130": { - "js": [ - { - "file": "assets/js/2130.0859056b.js", - "hash": "dd7cde4df1b0ee93", - "publicPath": "/assets/js/2130.0859056b.js" - } - ] - }, - "2237": { - "js": [ - { - "file": "assets/js/2237.a578a662.js", - "hash": "d9e8677ec3ec6f4a", - "publicPath": "/assets/js/2237.a578a662.js" - } - ] - }, - "2256": { - "js": [ - { - "file": "assets/js/11b43341.62e03792.js", - "hash": "1331b981b9c5d905", - "publicPath": "/assets/js/11b43341.62e03792.js" - } - ] - }, - "2438": { - "js": [ - { - "file": "assets/js/2438.17b7ef55.js", - "hash": "f5fbebc8b2dd473f", - "publicPath": "/assets/js/2438.17b7ef55.js" - } - ] - }, - "2555": { - "js": [ - { - "file": "assets/js/2555.3c26d2ab.js", - "hash": "9de5fdf964ec588e", - "publicPath": "/assets/js/2555.3c26d2ab.js" - } - ] - }, - "2634": { - "js": [ - { - "file": "assets/js/c4f5d8e4.8954aaa4.js", - "hash": "1f04c5881c7a48f8", - "publicPath": "/assets/js/c4f5d8e4.8954aaa4.js" - } - ] - }, - "2665": { - "js": [ - { - "file": "assets/js/2665.7c31be5b.js", - "hash": "09f16f4a4136a383", - "publicPath": "/assets/js/2665.7c31be5b.js" - } - ] - }, - "3049": { - "js": [ - { - "file": "assets/js/3049.85912d79.js", - "hash": "7307901749157a53", - "publicPath": "/assets/js/3049.85912d79.js" - } - ] - }, - "3088": { - "js": [ - { - "file": "assets/js/3088.5821fe72.js", - "hash": "0a04fbcac898953d", - "publicPath": "/assets/js/3088.5821fe72.js" - } - ] - }, - "3436": { - "js": [ - { - "file": "assets/js/3436.9d21bd6b.js", - "hash": "62d89fbb06c23992", - "publicPath": "/assets/js/3436.9d21bd6b.js" - } - ] - }, - "3593": { - "js": [ - { - "file": "assets/js/3593.502efe50.js", - "hash": "2a178dbcf3e5853b", - "publicPath": "/assets/js/3593.502efe50.js" - } - ] - }, - "3674": { - "js": [ - { - "file": "assets/js/3674.e152f57e.js", - "hash": "917d2af2363d2235", - "publicPath": "/assets/js/3674.e152f57e.js" - } - ] - }, - "3723": { - "js": [ - { - "file": "assets/js/3723.d568e688.js", - "hash": "dcc469160590b887", - "publicPath": "/assets/js/3723.d568e688.js" - } - ] - }, - "3872": { - "js": [ - { - "file": "assets/js/3872.f1271916.js", - "hash": "3153e353b19f5e58", - "publicPath": "/assets/js/3872.f1271916.js" - } - ] - }, - "3923": { - "js": [ - { - "file": "assets/js/3923.5f9e9fdc.js", - "hash": "60d88a9c13d0b7e8", - "publicPath": "/assets/js/3923.5f9e9fdc.js" - } - ] - }, - "4045": { - "js": [ - { - "file": "assets/js/4045.327cc9f2.js", - "hash": "d02f0a25613627c1", - "publicPath": "/assets/js/4045.327cc9f2.js" - } - ] - }, - "4219": { - "js": [ - { - "file": "assets/js/4219.077e2f86.js", - "hash": "a4b2aa90cfb0f8e1", - "publicPath": "/assets/js/4219.077e2f86.js" - } - ] - }, - "4673": { - "js": [ - { - "file": "assets/js/8cb5fad5.ace0548f.js", - "hash": "6481723b7b71f7f7", - "publicPath": "/assets/js/8cb5fad5.ace0548f.js" - } - ] - }, - "4728": { - "js": [ - { - "file": "assets/js/4728.e5d8a853.js", - "hash": "9e3c7afac66ebf2c", - "publicPath": "/assets/js/4728.e5d8a853.js" - } - ] - }, - "4737": { - "js": [ - { - "file": "assets/js/4737.c12ce803.js", - "hash": "07bd4b669cc9fbb7", - "publicPath": "/assets/js/4737.c12ce803.js" - } - ] - }, - "4787": { - "js": [ - { - "file": "assets/js/4787.47c183b9.js", - "hash": "fab07393fbb01fb3", - "publicPath": "/assets/js/4787.47c183b9.js" - } - ] - }, - "5354": { - "js": [ - { - "file": "assets/js/runtime~main.d453d9db.js", - "hash": "731fa37f7bebe89a", - "publicPath": "/assets/js/runtime~main.d453d9db.js" - } - ] - }, - "5359": { - "js": [ - { - "file": "assets/js/ec6d3eb8.ca081994.js", - "hash": "e1a2736b66ad570e", - "publicPath": "/assets/js/ec6d3eb8.ca081994.js" - } - ] - }, - "5438": { - "js": [ - { - "file": "assets/js/5438.e7e887c8.js", - "hash": "9a94eab82d205ca2", - "publicPath": "/assets/js/5438.e7e887c8.js" - } - ] - }, - "5680": { - "js": [ - { - "file": "assets/js/2e5b4b03.fe9dff7a.js", - "hash": "f24066d0f548a11c", - "publicPath": "/assets/js/2e5b4b03.fe9dff7a.js" - } - ] - }, - "5692": { - "js": [ - { - "file": "assets/js/5692.58c0cdc6.js", - "hash": "ed589091d03b4a82", - "publicPath": "/assets/js/5692.58c0cdc6.js" - } - ] - }, - "5742": { - "js": [ - { - "file": "assets/js/aba21aa0.3b844950.js", - "hash": "e9e7c37a444ebf6a", - "publicPath": "/assets/js/aba21aa0.3b844950.js" - } - ] - }, - "6246": { - "js": [ - { - "file": "assets/js/6246.0bfcb39b.js", - "hash": "46ea7d1be673b268", - "publicPath": "/assets/js/6246.0bfcb39b.js" - } - ] - }, - "6288": { - "js": [ - { - "file": "assets/js/6288.53f1fe4a.js", - "hash": "d1c2cb6efcabad88", - "publicPath": "/assets/js/6288.53f1fe4a.js" - } - ] - }, - "6402": { - "js": [ - { - "file": "assets/js/6402.14cd02a1.js", - "hash": "18db8b4e844bde8b", - "publicPath": "/assets/js/6402.14cd02a1.js" - } - ] - }, - "6678": { - "js": [ - { - "file": "assets/js/6678.7929a5db.js", - "hash": "a63263f8e9daf496", - "publicPath": "/assets/js/6678.7929a5db.js" - } - ] - }, - "6687": { - "js": [ - { - "file": "assets/js/af75b968.38286f67.js", - "hash": "758b3521c79db9c0", - "publicPath": "/assets/js/af75b968.38286f67.js" - } - ] - }, - "6735": { - "js": [ - { - "file": "assets/js/6735.f24050f0.js", - "hash": "a061021779dee3a6", - "publicPath": "/assets/js/6735.f24050f0.js" - } - ] - }, - "6969": { - "js": [ - { - "file": "assets/js/14eb3368.5faa8c56.js", - "hash": "8e3fd0cd8010917a", - "publicPath": "/assets/js/14eb3368.5faa8c56.js" - } - ] - }, - "7005": { - "js": [ - { - "file": "assets/js/7005.e6d836d3.js", - "hash": "641f6931e92ff392", - "publicPath": "/assets/js/7005.e6d836d3.js" - } - ] - }, - "7059": { - "js": [ - { - "file": "assets/js/7059.6e411ffe.js", - "hash": "9979eefb52e5f401", - "publicPath": "/assets/js/7059.6e411ffe.js" - } - ] - }, - "7098": { - "js": [ - { - "file": "assets/js/a7bd4aaa.581d97bf.js", - "hash": "0e7ca0d403a34b35", - "publicPath": "/assets/js/a7bd4aaa.581d97bf.js" - } - ] - }, - "7180": { - "js": [ - { - "file": "assets/js/7180.6d1c8d44.js", - "hash": "04d2100d977fadc2", - "publicPath": "/assets/js/7180.6d1c8d44.js" - } - ] - }, - "7563": { - "js": [ - { - "file": "assets/js/7563.d0e0f454.js", - "hash": "9d4e978675e2fbd4", - "publicPath": "/assets/js/7563.d0e0f454.js" - } - ] - }, - "7928": { - "js": [ - { - "file": "assets/js/7928.62dee32a.js", - "hash": "3650c6eba88731fa", - "publicPath": "/assets/js/7928.62dee32a.js" - } - ] - }, - "8007": { - "js": [ - { - "file": "assets/js/8007.cfe7f045.js", - "hash": "a6bee805f6809559", - "publicPath": "/assets/js/8007.cfe7f045.js" - } - ] - }, - "8119": { - "js": [ - { - "file": "assets/js/8119.c4eabbab.js", - "hash": "5bf6cb3a1d7a6692", - "publicPath": "/assets/js/8119.c4eabbab.js" - } - ] - }, - "8320": { - "js": [ - { - "file": "assets/js/8320.93852659.js", - "hash": "8d308cf2e8ab864b", - "publicPath": "/assets/js/8320.93852659.js" - } - ] - }, - "8336": { - "js": [ - { - "file": "assets/js/6f7f7b1d.64f02222.js", - "hash": "1fff2e21b3fc6756", - "publicPath": "/assets/js/6f7f7b1d.64f02222.js" - } - ] - }, - "8363": { - "js": [ - { - "file": "assets/js/8363.c84dffc7.js", - "hash": "b4e5a6e32364be98", - "publicPath": "/assets/js/8363.c84dffc7.js" - } - ] - }, - "8401": { - "js": [ - { - "file": "assets/js/17896441.ebddda61.js", - "hash": "8268a1583dd0afe3", - "publicPath": "/assets/js/17896441.ebddda61.js" - } - ] - }, - "8646": { - "js": [ - { - "file": "assets/js/8646.4b2e4233.js", - "hash": "73dd8fa647eeebe7", - "publicPath": "/assets/js/8646.4b2e4233.js" - } - ] - }, - "8792": { - "js": [ - { - "file": "assets/js/main.90d140f6.js", - "hash": "9e16e75cf5e43d78", - "publicPath": "/assets/js/main.90d140f6.js" - } - ] - }, - "9011": { - "js": [ - { - "file": "assets/js/922e3df9.9aa2bb8d.js", - "hash": "f16889cb416db085", - "publicPath": "/assets/js/922e3df9.9aa2bb8d.js" - } - ] - }, - "9048": { - "js": [ - { - "file": "assets/js/a94703ab.0bd9f570.js", - "hash": "43650c3ca99e0c6f", - "publicPath": "/assets/js/a94703ab.0bd9f570.js" - } - ] - }, - "9062": { - "js": [ - { - "file": "assets/js/f622ed5e.84a45866.js", - "hash": "cc202e486dccb312", - "publicPath": "/assets/js/f622ed5e.84a45866.js" - } - ] - }, - "9261": { - "js": [ - { - "file": "assets/js/9261.0f038ba8.js", - "hash": "e41c56870d17c215", - "publicPath": "/assets/js/9261.0f038ba8.js" - } - ] - }, - "9299": { - "js": [ - { - "file": "assets/js/9299.3bf1c0a1.js", - "hash": "fca4199a492024b7", - "publicPath": "/assets/js/9299.3bf1c0a1.js" - } - ] - }, - "9301": { - "js": [ - { - "file": "assets/js/9301.212450e5.js", - "hash": "5cd7d76dc4eca0b8", - "publicPath": "/assets/js/9301.212450e5.js" - } - ] - }, - "9348": { - "js": [ - { - "file": "assets/js/1e9767c5.f53ee4bf.js", - "hash": "01f58bbaaf9abff6", - "publicPath": "/assets/js/1e9767c5.f53ee4bf.js" - } - ] - }, - "9366": { - "js": [ - { - "file": "assets/js/3b3e214a.b3e3211e.js", - "hash": "d2169c2c39dabf2b", - "publicPath": "/assets/js/3b3e214a.b3e3211e.js" - } - ] - }, - "9557": { - "js": [ - { - "file": "assets/js/9557.f19bf2ef.js", - "hash": "0b59c13f7c238e16", - "publicPath": "/assets/js/9557.f19bf2ef.js" - } - ] - }, - "9647": { - "js": [ - { - "file": "assets/js/5e95c892.67c48228.js", - "hash": "e3f8a5719268f262", - "publicPath": "/assets/js/5e95c892.67c48228.js" - } - ] - }, - "9705": { - "js": [ - { - "file": "assets/js/b1abb4d6.c28c5ac9.js", - "hash": "95d7b41883a18aa3", - "publicPath": "/assets/js/b1abb4d6.c28c5ac9.js" - } - ] - } - } -} \ No newline at end of file diff --git a/docs/.docusaurus/client-modules.js b/docs/.docusaurus/client-modules.js deleted file mode 100644 index 10e4ede66..000000000 --- a/docs/.docusaurus/client-modules.js +++ /dev/null @@ -1,7 +0,0 @@ -export default [ - require("/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/node_modules/infima/dist/css/default/default.css"), - require("/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/node_modules/@docusaurus/theme-classic/lib/prism-include-languages"), - require("/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/node_modules/@docusaurus/theme-classic/lib/nprogress"), - require("/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/src/css/custom.css"), - require("/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/node_modules/@conduction/docusaurus-preset/src/css/brand.css"), -]; diff --git a/docs/.docusaurus/codeTranslations.json b/docs/.docusaurus/codeTranslations.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/docs/.docusaurus/codeTranslations.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/docs/.docusaurus/conduction-features-page/default/__plugin.json b/docs/.docusaurus/conduction-features-page/default/__plugin.json deleted file mode 100644 index 399e7848e..000000000 --- a/docs/.docusaurus/conduction-features-page/default/__plugin.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "conduction-features-page", - "id": "default" -} \ No newline at end of file diff --git a/docs/.docusaurus/conduction-features-page/default/conduction-features-page.json b/docs/.docusaurus/conduction-features-page/default/conduction-features-page.json deleted file mode 100644 index 5f2297d0a..000000000 --- a/docs/.docusaurus/conduction-features-page/default/conduction-features-page.json +++ /dev/null @@ -1 +0,0 @@ -{"features":[],"title":"Features","intro":null} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/__mdx-loader-dependency.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/__mdx-loader-dependency.json deleted file mode 100644 index 58944b117..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/__mdx-loader-dependency.json +++ /dev/null @@ -1 +0,0 @@ -{"options":{"path":"./","exclude":["**/node_modules/**","src/**"],"sidebarPath":"/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/sidebars.js","editUrl":"https://github.com/ConductionNL/keepiq/tree/development/docs/","editCurrentVersion":false,"editLocalizedFiles":false,"routeBasePath":"docs","tagsBasePath":"tags","include":["**/*.{md,mdx}"],"sidebarCollapsible":true,"sidebarCollapsed":true,"docsRootComponent":"@theme/DocsRoot","docVersionRootComponent":"@theme/DocVersionRoot","docRootComponent":"@theme/DocRoot","docItemComponent":"@theme/DocItem","docTagsListComponent":"@theme/DocTagsListPage","docTagDocListComponent":"@theme/DocTagDocListPage","docCategoryGeneratedIndexComponent":"@theme/DocCategoryGeneratedIndexPage","remarkPlugins":[],"rehypePlugins":[],"recmaPlugins":[],"beforeDefaultRemarkPlugins":[],"beforeDefaultRehypePlugins":[],"admonitions":true,"showLastUpdateTime":false,"showLastUpdateAuthor":false,"includeCurrentVersion":true,"disableVersioning":false,"versions":{},"breadcrumbs":true,"onInlineTags":"warn","id":"default"},"versionsMetadata":[{"versionName":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","path":"/docs","tagsPath":"/docs/tags","editUrl":"https://github.com/ConductionNL/keepiq/tree/development/docs/","isLast":true,"routePriority":-1,"sidebarFilePath":"/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/sidebars.js","contentPath":"/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs"}]} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/__plugin.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/__plugin.json deleted file mode 100644 index 3818ad026..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/__plugin.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "docusaurus-plugin-content-docs", - "id": "default" -} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-7fc.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-7fc.json deleted file mode 100644 index 750d72eaa..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-7fc.json +++ /dev/null @@ -1 +0,0 @@ -{"version":{"pluginId":"default","version":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","isLast":true,"docsSidebars":{"tutorialSidebar":[{"type":"link","href":"/docs/intro","label":"Keepiq","docId":"intro","unlisted":false},{"type":"category","label":"Tutorials","collapsible":true,"collapsed":false,"items":[{"type":"category","label":"User guide","collapsible":true,"collapsed":false,"items":[{"type":"link","href":"/docs/tutorials/user/first-launch","label":"Open Keepiq for the first time","docId":"tutorials/user/first-launch","unlisted":false}],"href":"/docs/category/user-guide"},{"type":"category","label":"Admin guide","collapsible":true,"collapsed":true,"items":[{"type":"link","href":"/docs/tutorials/admin/admin-settings","label":"Manage Keepiq settings","docId":"tutorials/admin/admin-settings","unlisted":false}],"href":"/docs/category/admin-guide"}],"href":"/docs/category/tutorials"},{"type":"link","href":"/docs/ARCHITECTURE","label":"Keepiq — Architecture & Data Model","docId":"ARCHITECTURE","unlisted":false},{"type":"link","href":"/docs/DESIGN-REFERENCES","label":"Keepiq — Design References & Dashboard Wireframes","docId":"DESIGN-REFERENCES","unlisted":false},{"type":"link","href":"/docs/FEATURES","label":"Keepiq — Feature Analysis & Product Strategy","docId":"FEATURES","unlisted":false}]},"docs":{"ARCHITECTURE":{"id":"ARCHITECTURE","title":"Keepiq — Architecture & Data Model","description":"1. Overview","sidebar":"tutorialSidebar"},"DESIGN-REFERENCES":{"id":"DESIGN-REFERENCES","title":"Keepiq — Design References & Dashboard Wireframes","description":"1. Design Inspiration Sources","sidebar":"tutorialSidebar"},"FEATURES":{"id":"FEATURES","title":"Keepiq — Feature Analysis & Product Strategy","description":"Executive Summary","sidebar":"tutorialSidebar"},"intro":{"id":"intro","title":"Keepiq","description":"An encrypted secrets manager for Nextcloud — password manager and key store for users and applications.","sidebar":"tutorialSidebar"},"tutorials/admin/admin-settings":{"id":"tutorials/admin/admin-settings","title":"Manage Keepiq settings","description":"Configure encryption, group-level sharing policies, and the audit log from the Nextcloud admin settings panel.","sidebar":"tutorialSidebar"},"tutorials/user/first-launch":{"id":"tutorials/user/first-launch","title":"Open Keepiq for the first time","description":"Open Keepiq, create your first vault entry, and confirm the encryption back end is connected.","sidebar":"tutorialSidebar"}}}} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-category-admin-guide-e8c.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-category-admin-guide-e8c.json deleted file mode 100644 index 2e64a4805..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-category-admin-guide-e8c.json +++ /dev/null @@ -1 +0,0 @@ -{"categoryGeneratedIndex":{"title":"Admin guide","description":"Org-wide administration — toggling features, configuring policies, and managing users or groups.","slug":"/category/admin-guide","permalink":"/docs/category/admin-guide","sidebar":"tutorialSidebar","navigation":{"previous":{"title":"Open Keepiq for the first time","permalink":"/docs/tutorials/user/first-launch"},"next":{"title":"Manage Keepiq settings","permalink":"/docs/tutorials/admin/admin-settings"}}}} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-category-tutorials-344.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-category-tutorials-344.json deleted file mode 100644 index 941c86c08..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-category-tutorials-344.json +++ /dev/null @@ -1 +0,0 @@ -{"categoryGeneratedIndex":{"title":"Tutorials","description":"Step-by-step walkthroughs for everyday tasks. The user track covers individual workflows; the admin track covers org-wide configuration.","slug":"/category/tutorials","permalink":"/docs/category/tutorials","sidebar":"tutorialSidebar","navigation":{"previous":{"title":"Keepiq","permalink":"/docs/intro"},"next":{"title":"User guide","permalink":"/docs/category/user-guide"}}}} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-category-user-guide-846.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-category-user-guide-846.json deleted file mode 100644 index 8d47259c9..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/p/docs-category-user-guide-846.json +++ /dev/null @@ -1 +0,0 @@ -{"categoryGeneratedIndex":{"title":"User guide","description":"Workflows for individual users — opening the app, customizing what's on screen, and getting day-to-day work done.","slug":"/category/user-guide","permalink":"/docs/category/user-guide","sidebar":"tutorialSidebar","navigation":{"previous":{"title":"Tutorials","permalink":"/docs/category/tutorials"},"next":{"title":"Open Keepiq for the first time","permalink":"/docs/tutorials/user/first-launch"}}}} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-architecture-md-1e9.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-architecture-md-1e9.json deleted file mode 100644 index d84ba3aaa..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-architecture-md-1e9.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "id": "ARCHITECTURE", - "title": "Keepiq — Architecture & Data Model", - "description": "1. Overview", - "source": "@site/ARCHITECTURE.md", - "sourceDirName": ".", - "slug": "/ARCHITECTURE", - "permalink": "/docs/ARCHITECTURE", - "draft": false, - "unlisted": false, - "editUrl": "https://github.com/ConductionNL/keepiq/tree/development/docs/ARCHITECTURE.md", - "tags": [], - "version": "current", - "frontMatter": {}, - "sidebar": "tutorialSidebar", - "previous": { - "title": "Manage Keepiq settings", - "permalink": "/docs/tutorials/admin/admin-settings" - }, - "next": { - "title": "Keepiq — Design References & Dashboard Wireframes", - "permalink": "/docs/DESIGN-REFERENCES" - } -} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-features-md-6f7.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-features-md-6f7.json deleted file mode 100644 index f241a747c..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-features-md-6f7.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "id": "FEATURES", - "title": "Keepiq — Feature Analysis & Product Strategy", - "description": "Executive Summary", - "source": "@site/FEATURES.md", - "sourceDirName": ".", - "slug": "/FEATURES", - "permalink": "/docs/FEATURES", - "draft": false, - "unlisted": false, - "editUrl": "https://github.com/ConductionNL/keepiq/tree/development/docs/FEATURES.md", - "tags": [], - "version": "current", - "frontMatter": {}, - "sidebar": "tutorialSidebar", - "previous": { - "title": "Keepiq — Design References & Dashboard Wireframes", - "permalink": "/docs/DESIGN-REFERENCES" - } -} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-intro-md-2e5.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-intro-md-2e5.json deleted file mode 100644 index 2e76ac338..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-intro-md-2e5.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "id": "intro", - "title": "Keepiq", - "description": "An encrypted secrets manager for Nextcloud — password manager and key store for users and applications.", - "source": "@site/intro.md", - "sourceDirName": ".", - "slug": "/intro", - "permalink": "/docs/intro", - "draft": false, - "unlisted": false, - "editUrl": "https://github.com/ConductionNL/keepiq/tree/development/docs/intro.md", - "tags": [], - "version": "current", - "sidebarPosition": 1, - "frontMatter": { - "sidebar_position": 1 - }, - "sidebar": "tutorialSidebar", - "next": { - "title": "Tutorials", - "permalink": "/docs/category/tutorials" - } -} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-tutorials-admin-01-admin-settings-md-922.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-tutorials-admin-01-admin-settings-md-922.json deleted file mode 100644 index 30be7e9a6..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-tutorials-admin-01-admin-settings-md-922.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "tutorials/admin/admin-settings", - "title": "Manage Keepiq settings", - "description": "Configure encryption, group-level sharing policies, and the audit log from the Nextcloud admin settings panel.", - "source": "@site/tutorials/admin/01-admin-settings.md", - "sourceDirName": "tutorials/admin", - "slug": "/tutorials/admin/admin-settings", - "permalink": "/docs/tutorials/admin/admin-settings", - "draft": false, - "unlisted": false, - "editUrl": "https://github.com/ConductionNL/keepiq/tree/development/docs/tutorials/admin/01-admin-settings.md", - "tags": [], - "version": "current", - "sidebarPosition": 1, - "frontMatter": { - "sidebar_position": 1, - "title": "Manage Keepiq settings", - "description": "Configure encryption, group-level sharing policies, and the audit log from the Nextcloud admin settings panel." - }, - "sidebar": "tutorialSidebar", - "previous": { - "title": "Admin guide", - "permalink": "/docs/category/admin-guide" - }, - "next": { - "title": "Keepiq — Architecture & Data Model", - "permalink": "/docs/ARCHITECTURE" - } -} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-tutorials-user-01-first-launch-md-ec6.json b/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-tutorials-user-01-first-launch-md-ec6.json deleted file mode 100644 index 977e65c3d..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-docs/default/site-tutorials-user-01-first-launch-md-ec6.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "tutorials/user/first-launch", - "title": "Open Keepiq for the first time", - "description": "Open Keepiq, create your first vault entry, and confirm the encryption back end is connected.", - "source": "@site/tutorials/user/01-first-launch.md", - "sourceDirName": "tutorials/user", - "slug": "/tutorials/user/first-launch", - "permalink": "/docs/tutorials/user/first-launch", - "draft": false, - "unlisted": false, - "editUrl": "https://github.com/ConductionNL/keepiq/tree/development/docs/tutorials/user/01-first-launch.md", - "tags": [], - "version": "current", - "sidebarPosition": 1, - "frontMatter": { - "sidebar_position": 1, - "title": "Open Keepiq for the first time", - "description": "Open Keepiq, create your first vault entry, and confirm the encryption back end is connected." - }, - "sidebar": "tutorialSidebar", - "previous": { - "title": "User guide", - "permalink": "/docs/category/user-guide" - }, - "next": { - "title": "Admin guide", - "permalink": "/docs/category/admin-guide" - } -} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus-plugin-content-pages/default/__plugin.json b/docs/.docusaurus/docusaurus-plugin-content-pages/default/__plugin.json deleted file mode 100644 index b141f718a..000000000 --- a/docs/.docusaurus/docusaurus-plugin-content-pages/default/__plugin.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "docusaurus-plugin-content-pages", - "id": "default" -} \ No newline at end of file diff --git a/docs/.docusaurus/docusaurus.config.mjs b/docs/.docusaurus/docusaurus.config.mjs deleted file mode 100644 index 3a8e22a75..000000000 --- a/docs/.docusaurus/docusaurus.config.mjs +++ /dev/null @@ -1,471 +0,0 @@ -/* - * AUTOGENERATED - DON'T EDIT - * Your edits in this file will be overwritten in the next build! - * Modify the docusaurus.config.js file at your site's root instead. - */ -export default { - "title": "Keepiq", - "tagline": "Self-hosted password and secrets vault. Per-user, per-team, audited.", - "favicon": "img/favicon.svg", - "url": "https://doriath.conduction.nl", - "baseUrl": "/", - "trailingSlash": true, - "organizationName": "ConductionNL", - "projectName": "keepiq", - "customFields": { - "appVersion": "0.1.3" - }, - "onBrokenLinks": "warn", - "staticDirectories": [ - "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/doriath/docs/node_modules/@conduction/docusaurus-preset/static", - "static" - ], - "i18n": { - "defaultLocale": "en", - "locales": [ - "en" - ], - "localeConfigs": { - "en": { - "label": "English" - } - }, - "path": "i18n" - }, - "presets": [ - [ - "classic", - { - "docs": { - "path": "./", - "exclude": [ - "**/node_modules/**", - "src/**" - ], - "sidebarPath": "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/doriath/docs/sidebars.js", - "editUrl": "https://github.com/ConductionNL/keepiq/tree/development/docs/" - }, - "blog": false, - "theme": { - "customCss": "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/doriath/docs/src/css/custom.css" - }, - "sitemap": { - "changefreq": null, - "priority": null, - "lastmod": "date", - "ignorePatterns": [ - "/academy/tags/**", - "/nl/academy/tags/**", - "/en/academy/tags/**", - "/de/academy/tags/**", - "/fr/academy/tags/**", - "/page/**", - "/nl/page/**", - "/en/page/**", - "/de/page/**", - "/fr/page/**" - ], - "filename": "sitemap.xml" - } - } - ] - ], - "themes": [ - "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/doriath/docs/node_modules/@conduction/docusaurus-preset/src/theme.js", - "@docusaurus/theme-mermaid" - ], - "themeConfig": { - "colorMode": { - "defaultMode": "light", - "disableSwitch": false, - "respectPrefersColorScheme": true - }, - "navbar": { - "title": "Keepiq", - "logo": { - "alt": "Keepiq avatar", - "src": "img/logo.svg", - "srcDark": "img/logo-dark.svg" - }, - "items": [ - { - "type": "docSidebar", - "sidebarId": "tutorialSidebar", - "position": "left", - "label": "Documentation" - }, - { - "href": "https://github.com/ConductionNL/keepiq", - "label": "GitHub", - "position": "right" - }, - { - "type": "localeDropdown", - "position": "right", - "dropdownItemsBefore": [], - "dropdownItemsAfter": [] - } - ], - "hideOnScroll": false - }, - "footer": { - "style": "dark", - "links": [ - { - "title": "Conduction", - "items": [ - { - "label": "About", - "href": "https://conduction.nl/about" - }, - { - "label": "Open source", - "href": "https://conduction.nl/about#opensource" - }, - { - "label": "Team", - "href": "https://conduction.nl/about#team" - }, - { - "label": "ISO", - "href": "https://conduction.nl/iso" - } - ] - } - ], - "copyright": "Conduction B.V. · KvK 76741850 · BTW NL860784241B01 · IBAN NL51 ABNA 0868951550 · Lauriergracht 14h, 1016 RR Amsterdam · 2026" - }, - "minigames": false, - "footerBrand": null, - "legalLinks": { - "privacy": "https://www.conduction.nl/privacy", - "terms": "https://www.conduction.nl/terms", - "iso": "https://www.conduction.nl/iso" - }, - "image": "img/og-keepiq.png", - "metadata": [ - { - "name": "twitter:site", - "content": "@ConductionNL" - }, - { - "name": "twitter:card", - "content": "summary_large_image" - }, - { - "property": "og:type", - "content": "website" - } - ], - "prism": { - "theme": { - "plain": { - "color": "#393A34", - "backgroundColor": "#f6f8fa" - }, - "styles": [ - { - "types": [ - "comment", - "prolog", - "doctype", - "cdata" - ], - "style": { - "color": "#999988", - "fontStyle": "italic" - } - }, - { - "types": [ - "namespace" - ], - "style": { - "opacity": 0.7 - } - }, - { - "types": [ - "string", - "attr-value" - ], - "style": { - "color": "#e3116c" - } - }, - { - "types": [ - "punctuation", - "operator" - ], - "style": { - "color": "#393A34" - } - }, - { - "types": [ - "entity", - "url", - "symbol", - "number", - "boolean", - "variable", - "constant", - "property", - "regex", - "inserted" - ], - "style": { - "color": "#36acaa" - } - }, - { - "types": [ - "atrule", - "keyword", - "attr-name", - "selector" - ], - "style": { - "color": "#00a4db" - } - }, - { - "types": [ - "function", - "deleted", - "tag" - ], - "style": { - "color": "#d73a49" - } - }, - { - "types": [ - "function-variable" - ], - "style": { - "color": "#6f42c1" - } - }, - { - "types": [ - "tag", - "selector", - "keyword" - ], - "style": { - "color": "#00009f" - } - } - ] - }, - "darkTheme": { - "plain": { - "color": "#F8F8F2", - "backgroundColor": "#282A36" - }, - "styles": [ - { - "types": [ - "prolog", - "constant", - "builtin" - ], - "style": { - "color": "rgb(189, 147, 249)" - } - }, - { - "types": [ - "inserted", - "function" - ], - "style": { - "color": "rgb(80, 250, 123)" - } - }, - { - "types": [ - "deleted" - ], - "style": { - "color": "rgb(255, 85, 85)" - } - }, - { - "types": [ - "changed" - ], - "style": { - "color": "rgb(255, 184, 108)" - } - }, - { - "types": [ - "punctuation", - "symbol" - ], - "style": { - "color": "rgb(248, 248, 242)" - } - }, - { - "types": [ - "string", - "char", - "tag", - "selector" - ], - "style": { - "color": "rgb(255, 121, 198)" - } - }, - { - "types": [ - "keyword", - "variable" - ], - "style": { - "color": "rgb(189, 147, 249)", - "fontStyle": "italic" - } - }, - { - "types": [ - "comment" - ], - "style": { - "color": "rgb(98, 114, 164)" - } - }, - { - "types": [ - "attr-name" - ], - "style": { - "color": "rgb(241, 250, 140)" - } - } - ] - }, - "additionalLanguages": [], - "magicComments": [ - { - "className": "theme-code-block-highlighted-line", - "line": "highlight-next-line", - "block": { - "start": "highlight-start", - "end": "highlight-end" - } - } - ] - }, - "mermaid": { - "theme": { - "light": "default", - "dark": "dark" - }, - "options": {} - }, - "docs": { - "versionPersistence": "localStorage", - "sidebar": { - "hideable": false, - "autoCollapseCategories": false - } - }, - "blog": { - "sidebar": { - "groupByYear": true - } - }, - "tableOfContents": { - "minHeadingLevel": 2, - "maxHeadingLevel": 3 - } - }, - "headTags": [ - { - "tagName": "script", - "attributes": { - "type": "application/ld+json" - }, - "innerHTML": "{\"@context\":\"https://schema.org\",\"@type\":\"Organization\",\"@id\":\"https://www.conduction.nl/#org\",\"name\":\"Conduction B.V.\",\"alternateName\":\"Conduction\",\"url\":\"https://www.conduction.nl/\",\"logo\":\"https://www.conduction.nl/img/brand/avatar-conduction-gold-on-white.svg\",\"foundingDate\":\"2019\",\"description\":\"Dutch open-source software company building EUPL-1.2 apps for the Nextcloud workspace.\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Lauriergracht 14h\",\"postalCode\":\"1016 RR\",\"addressLocality\":\"Amsterdam\",\"addressCountry\":\"NL\"},\"email\":\"info@conduction.nl\",\"telephone\":\"+31-85-303-6840\",\"taxID\":\"NL860784241B01\",\"vatID\":\"NL860784241B01\",\"identifier\":{\"@type\":\"PropertyValue\",\"propertyID\":\"KvK\",\"value\":\"76741850\"},\"sameAs\":[\"https://codeberg.org/Conduction\",\"https://www.linkedin.com/company/conduction/\"]}", - "customElement": false - }, - { - "tagName": "script", - "attributes": { - "type": "application/ld+json" - }, - "innerHTML": "{\"@context\":\"https://schema.org\",\"@type\":\"WebSite\",\"@id\":\"https://doriath.conduction.nl/#website\",\"url\":\"https://doriath.conduction.nl/\",\"name\":\"Keepiq\",\"publisher\":{\"@id\":\"https://www.conduction.nl/#org\"},\"inLanguage\":[\"en\"]}", - "customElement": false - } - ], - "plugins": [ - [ - "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/doriath/docs/node_modules/@conduction/docusaurus-preset/src/plugins/ai-crawling.js", - {} - ], - [ - "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/doriath/docs/node_modules/@conduction/docusaurus-preset/src/plugins/indexnow.js", - {} - ], - [ - "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/doriath/docs/node_modules/@conduction/docusaurus-preset/src/plugins/features-page.js", - {} - ] - ], - "onBrokenAnchors": "warn", - "markdown": { - "mermaid": true, - "hooks": { - "onBrokenMarkdownImages": "warn", - "onBrokenMarkdownLinks": "warn" - }, - "format": "mdx", - "emoji": true, - "mdx1Compat": { - "comments": true, - "admonitions": true, - "headingIds": true - }, - "anchors": { - "maintainCase": false - } - }, - "baseUrlIssueBanner": true, - "storage": { - "type": "localStorage", - "namespace": false - }, - "future": { - "v4": { - "removeLegacyPostBuildHeadAttribute": false, - "useCssCascadeLayers": false, - "siteStorageNamespacing": false, - "fasterByDefault": false, - "mdx1CompatDisabledByDefault": false - }, - "faster": { - "swcJsLoader": false, - "swcJsMinimizer": false, - "swcHtmlMinimizer": false, - "lightningCssMinimizer": false, - "mdxCrossCompilerCache": false, - "rspackBundler": false, - "rspackPersistentCache": false, - "ssgWorkerThreads": false, - "gitEagerVcs": false - }, - "experimental_vcs": {}, - "experimental_router": "browser" - }, - "onDuplicateRoutes": "warn", - "scripts": [], - "stylesheets": [], - "clientModules": [], - "titleDelimiter": "|", - "noIndex": false -}; diff --git a/docs/.docusaurus/globalData.json b/docs/.docusaurus/globalData.json deleted file mode 100644 index e0e5cded5..000000000 --- a/docs/.docusaurus/globalData.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "docusaurus-plugin-content-docs": { - "default": { - "path": "/docs", - "versions": [ - { - "name": "current", - "label": "Next", - "isLast": true, - "path": "/docs", - "mainDocId": "intro", - "docs": [ - { - "id": "ARCHITECTURE", - "path": "/docs/ARCHITECTURE", - "sidebar": "tutorialSidebar" - }, - { - "id": "DESIGN-REFERENCES", - "path": "/docs/DESIGN-REFERENCES", - "sidebar": "tutorialSidebar" - }, - { - "id": "FEATURES", - "path": "/docs/FEATURES", - "sidebar": "tutorialSidebar" - }, - { - "id": "intro", - "path": "/docs/intro", - "sidebar": "tutorialSidebar" - }, - { - "id": "tutorials/admin/admin-settings", - "path": "/docs/tutorials/admin/admin-settings", - "sidebar": "tutorialSidebar" - }, - { - "id": "tutorials/user/first-launch", - "path": "/docs/tutorials/user/first-launch", - "sidebar": "tutorialSidebar" - }, - { - "id": "/category/tutorials", - "path": "/docs/category/tutorials", - "sidebar": "tutorialSidebar" - }, - { - "id": "/category/user-guide", - "path": "/docs/category/user-guide", - "sidebar": "tutorialSidebar" - }, - { - "id": "/category/admin-guide", - "path": "/docs/category/admin-guide", - "sidebar": "tutorialSidebar" - } - ], - "draftIds": [], - "sidebars": { - "tutorialSidebar": { - "link": { - "path": "/docs/intro", - "label": "intro" - } - } - } - } - ], - "breadcrumbs": true - } - } -} \ No newline at end of file diff --git a/docs/.docusaurus/i18n.json b/docs/.docusaurus/i18n.json deleted file mode 100644 index 44cec4121..000000000 --- a/docs/.docusaurus/i18n.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "defaultLocale": "en", - "locales": [ - "en" - ], - "path": "i18n", - "currentLocale": "en", - "localeConfigs": { - "en": { - "label": "English", - "direction": "ltr", - "htmlLang": "en", - "calendar": "gregory", - "path": "en", - "translate": false, - "url": "https://doriath.conduction.nl", - "baseUrl": "/" - } - } -} \ No newline at end of file diff --git a/docs/.docusaurus/registry.js b/docs/.docusaurus/registry.js deleted file mode 100644 index 4e75dee1f..000000000 --- a/docs/.docusaurus/registry.js +++ /dev/null @@ -1,23 +0,0 @@ -export default { - "11b43341": [() => import(/* webpackChunkName: "11b43341" */ "@generated/docusaurus-plugin-content-docs/default/p/docs-7fc.json"), "@generated/docusaurus-plugin-content-docs/default/p/docs-7fc.json", require.resolveWeak("@generated/docusaurus-plugin-content-docs/default/p/docs-7fc.json")], - "14eb3368": [() => import(/* webpackChunkName: "14eb3368" */ "@theme/DocCategoryGeneratedIndexPage"), "@theme/DocCategoryGeneratedIndexPage", require.resolveWeak("@theme/DocCategoryGeneratedIndexPage")], - "17896441": [() => import(/* webpackChunkName: "17896441" */ "@theme/DocItem"), "@theme/DocItem", require.resolveWeak("@theme/DocItem")], - "1e9767c5": [() => import(/* webpackChunkName: "1e9767c5" */ "@site/ARCHITECTURE.md"), "@site/ARCHITECTURE.md", require.resolveWeak("@site/ARCHITECTURE.md")], - "2e5b4b03": [() => import(/* webpackChunkName: "2e5b4b03" */ "@site/intro.md"), "@site/intro.md", require.resolveWeak("@site/intro.md")], - "3b3e214a": [() => import(/* webpackChunkName: "3b3e214a" */ "@site/DESIGN-REFERENCES.md"), "@site/DESIGN-REFERENCES.md", require.resolveWeak("@site/DESIGN-REFERENCES.md")], - "4081bc5c": [() => import(/* webpackChunkName: "4081bc5c" */ "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/node_modules/@conduction/docusaurus-preset/src/components/FeaturesPage/FeaturesPage.jsx"), "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/node_modules/@conduction/docusaurus-preset/src/components/FeaturesPage/FeaturesPage.jsx", require.resolveWeak("/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/node_modules/@conduction/docusaurus-preset/src/components/FeaturesPage/FeaturesPage.jsx")], - "5e95c892": [() => import(/* webpackChunkName: "5e95c892" */ "@theme/DocsRoot"), "@theme/DocsRoot", require.resolveWeak("@theme/DocsRoot")], - "5e9f5e1a": [() => import(/* webpackChunkName: "5e9f5e1a" */ "@generated/docusaurus.config"), "@generated/docusaurus.config", require.resolveWeak("@generated/docusaurus.config")], - "6f7f7b1d": [() => import(/* webpackChunkName: "6f7f7b1d" */ "@site/FEATURES.md"), "@site/FEATURES.md", require.resolveWeak("@site/FEATURES.md")], - "8cb5fad5": [() => import(/* webpackChunkName: "8cb5fad5" */ "@generated/docusaurus-plugin-content-docs/default/p/docs-category-admin-guide-e8c.json"), "@generated/docusaurus-plugin-content-docs/default/p/docs-category-admin-guide-e8c.json", require.resolveWeak("@generated/docusaurus-plugin-content-docs/default/p/docs-category-admin-guide-e8c.json")], - "922e3df9": [() => import(/* webpackChunkName: "922e3df9" */ "@site/tutorials/admin/01-admin-settings.md"), "@site/tutorials/admin/01-admin-settings.md", require.resolveWeak("@site/tutorials/admin/01-admin-settings.md")], - "a7456010": [() => import(/* webpackChunkName: "a7456010" */ "@generated/docusaurus-plugin-content-pages/default/__plugin.json"), "@generated/docusaurus-plugin-content-pages/default/__plugin.json", require.resolveWeak("@generated/docusaurus-plugin-content-pages/default/__plugin.json")], - "a7bd4aaa": [() => import(/* webpackChunkName: "a7bd4aaa" */ "@theme/DocVersionRoot"), "@theme/DocVersionRoot", require.resolveWeak("@theme/DocVersionRoot")], - "a94703ab": [() => import(/* webpackChunkName: "a94703ab" */ "@theme/DocRoot"), "@theme/DocRoot", require.resolveWeak("@theme/DocRoot")], - "aba21aa0": [() => import(/* webpackChunkName: "aba21aa0" */ "@generated/docusaurus-plugin-content-docs/default/__plugin.json"), "@generated/docusaurus-plugin-content-docs/default/__plugin.json", require.resolveWeak("@generated/docusaurus-plugin-content-docs/default/__plugin.json")], - "af516ec7": [() => import(/* webpackChunkName: "af516ec7" */ "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/.docusaurus/conduction-features-page/default/conduction-features-page.json"), "/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/.docusaurus/conduction-features-page/default/conduction-features-page.json", require.resolveWeak("/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/keepiq/docs/.docusaurus/conduction-features-page/default/conduction-features-page.json")], - "af75b968": [() => import(/* webpackChunkName: "af75b968" */ "@generated/docusaurus-plugin-content-docs/default/p/docs-category-tutorials-344.json"), "@generated/docusaurus-plugin-content-docs/default/p/docs-category-tutorials-344.json", require.resolveWeak("@generated/docusaurus-plugin-content-docs/default/p/docs-category-tutorials-344.json")], - "b1abb4d6": [() => import(/* webpackChunkName: "b1abb4d6" */ "@generated/docusaurus-plugin-content-docs/default/p/docs-category-user-guide-846.json"), "@generated/docusaurus-plugin-content-docs/default/p/docs-category-user-guide-846.json", require.resolveWeak("@generated/docusaurus-plugin-content-docs/default/p/docs-category-user-guide-846.json")], - "c4f5d8e4": [() => import(/* webpackChunkName: "c4f5d8e4" */ "@site/src/pages/index.js"), "@site/src/pages/index.js", require.resolveWeak("@site/src/pages/index.js")], - "ec6d3eb8": [() => import(/* webpackChunkName: "ec6d3eb8" */ "@site/tutorials/user/01-first-launch.md"), "@site/tutorials/user/01-first-launch.md", require.resolveWeak("@site/tutorials/user/01-first-launch.md")], - "f622ed5e": [() => import(/* webpackChunkName: "f622ed5e" */ "@generated/conduction-features-page/default/__plugin.json"), "@generated/conduction-features-page/default/__plugin.json", require.resolveWeak("@generated/conduction-features-page/default/__plugin.json")],}; diff --git a/docs/.docusaurus/routes.js b/docs/.docusaurus/routes.js deleted file mode 100644 index cbe14ce66..000000000 --- a/docs/.docusaurus/routes.js +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react'; -import ComponentCreator from '@docusaurus/ComponentCreator'; - -export default [ - { - path: '/features/', - component: ComponentCreator('/features/', '1d2'), - exact: true - }, - { - path: '/docs/', - component: ComponentCreator('/docs/', '7e4'), - routes: [ - { - path: '/docs/', - component: ComponentCreator('/docs/', '305'), - routes: [ - { - path: '/docs/', - component: ComponentCreator('/docs/', '940'), - routes: [ - { - path: '/docs/ARCHITECTURE/', - component: ComponentCreator('/docs/ARCHITECTURE/', '34e'), - exact: true, - sidebar: "tutorialSidebar" - }, - { - path: '/docs/category/admin-guide/', - component: ComponentCreator('/docs/category/admin-guide/', 'bc9'), - exact: true, - sidebar: "tutorialSidebar" - }, - { - path: '/docs/category/tutorials/', - component: ComponentCreator('/docs/category/tutorials/', 'ae9'), - exact: true, - sidebar: "tutorialSidebar" - }, - { - path: '/docs/category/user-guide/', - component: ComponentCreator('/docs/category/user-guide/', 'a58'), - exact: true, - sidebar: "tutorialSidebar" - }, - { - path: '/docs/DESIGN-REFERENCES/', - component: ComponentCreator('/docs/DESIGN-REFERENCES/', '7fb'), - exact: true, - sidebar: "tutorialSidebar" - }, - { - path: '/docs/FEATURES/', - component: ComponentCreator('/docs/FEATURES/', 'fcf'), - exact: true, - sidebar: "tutorialSidebar" - }, - { - path: '/docs/intro/', - component: ComponentCreator('/docs/intro/', '224'), - exact: true, - sidebar: "tutorialSidebar" - }, - { - path: '/docs/tutorials/admin/admin-settings/', - component: ComponentCreator('/docs/tutorials/admin/admin-settings/', '944'), - exact: true, - sidebar: "tutorialSidebar" - }, - { - path: '/docs/tutorials/user/first-launch/', - component: ComponentCreator('/docs/tutorials/user/first-launch/', '391'), - exact: true, - sidebar: "tutorialSidebar" - } - ] - } - ] - } - ] - }, - { - path: '/', - component: ComponentCreator('/', '2e1'), - exact: true - }, - { - path: '*', - component: ComponentCreator('*'), - }, -]; diff --git a/docs/.docusaurus/routesChunkNames.json b/docs/.docusaurus/routesChunkNames.json deleted file mode 100644 index 69f1a654b..000000000 --- a/docs/.docusaurus/routesChunkNames.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "/features/-1d2": { - "__comp": "4081bc5c", - "__context": { - "plugin": "f622ed5e" - }, - "data": "af516ec7" - }, - "/docs/-7e4": { - "__comp": "5e95c892", - "__context": { - "plugin": "aba21aa0" - } - }, - "/docs/-305": { - "__comp": "a7bd4aaa", - "__props": "11b43341" - }, - "/docs/-940": { - "__comp": "a94703ab" - }, - "/docs/ARCHITECTURE/-34e": { - "__comp": "17896441", - "content": "1e9767c5" - }, - "/docs/category/admin-guide/-bc9": { - "__comp": "14eb3368", - "__props": "8cb5fad5" - }, - "/docs/category/tutorials/-ae9": { - "__comp": "14eb3368", - "__props": "af75b968" - }, - "/docs/category/user-guide/-a58": { - "__comp": "14eb3368", - "__props": "b1abb4d6" - }, - "/docs/DESIGN-REFERENCES/-7fb": { - "__comp": "17896441", - "content": "3b3e214a" - }, - "/docs/FEATURES/-fcf": { - "__comp": "17896441", - "content": "6f7f7b1d" - }, - "/docs/intro/-224": { - "__comp": "17896441", - "content": "2e5b4b03" - }, - "/docs/tutorials/admin/admin-settings/-944": { - "__comp": "17896441", - "content": "922e3df9" - }, - "/docs/tutorials/user/first-launch/-391": { - "__comp": "17896441", - "content": "ec6d3eb8" - }, - "/-2e1": { - "__comp": "c4f5d8e4", - "__context": { - "plugin": "a7456010" - }, - "config": "5e9f5e1a" - } -} \ No newline at end of file diff --git a/docs/.docusaurus/site-metadata.json b/docs/.docusaurus/site-metadata.json deleted file mode 100644 index b17d4b9f1..000000000 --- a/docs/.docusaurus/site-metadata.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "docusaurusVersion": "3.10.1", - "siteVersion": "0.0.0", - "pluginVersions": { - "docusaurus-plugin-content-docs": { - "type": "package", - "name": "@docusaurus/plugin-content-docs", - "version": "3.10.1" - }, - "docusaurus-plugin-content-pages": { - "type": "package", - "name": "@docusaurus/plugin-content-pages", - "version": "3.10.1" - }, - "docusaurus-plugin-sitemap": { - "type": "package", - "name": "@docusaurus/plugin-sitemap", - "version": "3.10.1" - }, - "docusaurus-plugin-svgr": { - "type": "package", - "name": "@docusaurus/plugin-svgr", - "version": "3.10.1" - }, - "docusaurus-theme-classic": { - "type": "package", - "name": "@docusaurus/theme-classic", - "version": "3.10.1" - }, - "conduction-ai-crawling": { - "type": "package", - "name": "@conduction/docusaurus-preset", - "version": "3.9.0" - }, - "conduction-indexnow": { - "type": "package", - "name": "@conduction/docusaurus-preset", - "version": "3.9.0" - }, - "conduction-features-page": { - "type": "package", - "name": "@conduction/docusaurus-preset", - "version": "3.9.0" - }, - "@conduction/docusaurus-theme": { - "type": "package", - "name": "@conduction/docusaurus-preset", - "version": "3.9.0" - }, - "docusaurus-theme-mermaid": { - "type": "package", - "name": "@docusaurus/theme-mermaid", - "version": "3.10.1" - } - } -} \ No newline at end of file diff --git a/docs/.docusaurus/site-storage.json b/docs/.docusaurus/site-storage.json deleted file mode 100644 index c769c71c4..000000000 --- a/docs/.docusaurus/site-storage.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "localStorage", - "namespace": "" -} \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c10c3e194..a8232e68c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -344,6 +344,36 @@ 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 contacts that weren't +carried: unreachable grantees, contacts the owner didn't tick (or declined), and +contacts with a break-glass in flight. It invalidates exactly those, recording +`grantor_rotation_in_flight` for the last group and `grantor_rotation` for the +rest. The recovery form prompts re-establishing only an unreachable contact. A +resumed rotation reads the removed contacts back and names them without a +prompt, and the Emergency Access view shows a text-only notice on each. 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 +615,71 @@ 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; single-use proofs.** The nonce is + HMAC-authenticated with the instance secret over its random part, the caller, + the purpose and an expiry, so issuing and checking it needs no store. Binding + to the operation's parameters is not enough on its own: on an upsert route a + replayed designate proof would recreate a contact the owner just revoked. So + once a proof verifies, its nonce is consumed in the distributed cache + (`keepiq_proof_nonce`, atomic `add()` on a memcache) for the rest of its + lifetime, and never before verification. Best-effort: without a memcache the + NullCache detects no reuse (logged once as a warning) and the flows keep + working. +- **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/docs/compromise-recovery.md b/docs/compromise-recovery.md index 85f7e578b..3ed1a8d47 100644 --- a/docs/compromise-recovery.md +++ b/docs/compromise-recovery.md @@ -58,7 +58,7 @@ Progress is shown inside the recovery dialog as `n of m` across every store. | Your attachment access | Re-wrapped. Other recipients' access is untouched | | Pending fill-in requests | Locked during the migration, then re-pointed to the new key | | Link shares | **Revoked.** Their snapshots were sealed to the leaked key; re-share afterwards | -| Emergency access | **Invalidated.** The envelope is sealed to your contact's key and escrows your old private key, so you cannot re-wrap it alone — re-establish emergency access afterwards | +| Emergency access | **Carried only for the contacts you tick.** Before the rotation starts you choose which contacts get your new key, and none is ticked by default. A contact that is unticked, unreachable, or has an emergency-access request pending or approved is removed. The completion screen and the Emergency Access page tell you which ones. Add a contact again only if you're sure you added it yourself | ## If it is interrupted diff --git a/docusaurus/docusaurus.config.js b/docusaurus/docusaurus.config.js deleted file mode 100644 index ed967a6a4..000000000 --- a/docusaurus/docusaurus.config.js +++ /dev/null @@ -1,103 +0,0 @@ -// @ts-check - -/** @type {import('@docusaurus/types').Config} */ -const config = { - title: 'Keepiq', - tagline: 'Encrypted secrets manager for Nextcloud', - url: 'https://keepiq.app', - baseUrl: '/', - - // GitHub pages deployment config - organizationName: 'ConductionNL', - projectName: 'keepiq', - trailingSlash: false, - - onBrokenLinks: 'warn', - onBrokenMarkdownLinks: 'warn', - - i18n: { - defaultLocale: 'en', - locales: ['en'], - }, - - presets: [ - [ - 'classic', - /** @type {import('@docusaurus/preset-classic').Options} */ - ({ - docs: { - path: '../docs', - sidebarPath: require.resolve('./sidebars.js'), - editUrl: - 'https://github.com/ConductionNL/keepiq/tree/main/docusaurus/', - }, - blog: false, - theme: { - customCss: require.resolve('./src/css/custom.css'), - }, - }), - ], - ], - - themeConfig: - /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ - ({ - navbar: { - title: 'Keepiq', - logo: { - alt: 'Keepiq Logo', - src: 'img/logo.svg', - }, - items: [ - { - type: 'docSidebar', - sidebarId: 'tutorialSidebar', - position: 'left', - label: 'Documentation', - }, - { - href: 'https://github.com/ConductionNL/keepiq', - label: 'GitHub', - position: 'right', - }, - ], - }, - footer: { - style: 'dark', - links: [ - { - title: 'Docs', - items: [ - { - label: 'Documentation', - to: '/docs/FEATURES', - }, - ], - }, - { - title: 'Community', - items: [ - { - label: 'GitHub', - href: 'https://github.com/ConductionNL/keepiq', - }, - ], - }, - ], - copyright: `Copyright \u00a9 ${new Date().getFullYear()} for Open Webconcept by Conduction B.V.`, - }, - prism: { - theme: require('prism-react-renderer/themes/github'), - darkTheme: require('prism-react-renderer/themes/dracula'), - }, - mermaid: { - theme: { light: 'default', dark: 'dark' }, - }, - }), - markdown: { - mermaid: true, - }, - themes: ['@docusaurus/theme-mermaid'], -}; - -module.exports = config; diff --git a/docusaurus/package.json b/docusaurus/package.json deleted file mode 100644 index aa88da61c..000000000 --- a/docusaurus/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "keepiq-docs", - "version": "0.0.0", - "private": true, - "scripts": { - "docusaurus": "docusaurus", - "start": "docusaurus start", - "build": "docusaurus build", - "swizzle": "docusaurus swizzle", - "deploy": "docusaurus deploy", - "clear": "docusaurus clear", - "serve": "docusaurus serve", - "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids", - "ci": "npm ci --legacy-peer-deps && npm run build" - }, - "dependencies": { - "@docusaurus/core": "^3.7.0", - "@docusaurus/preset-classic": "^3.7.0", - "@docusaurus/theme-mermaid": "^3.7.0", - "@mdx-js/react": "^3.1.0", - "clsx": "^1.2.1", - "prism-react-renderer": "^1.3.5", - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@docusaurus/module-type-aliases": "^3.7.0" - }, - "browserslist": { - "production": [ - ">0.5%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "engines": { - "node": ">=18.0" - } -} diff --git a/docusaurus/sidebars.js b/docusaurus/sidebars.js deleted file mode 100644 index 74c2e6ce1..000000000 --- a/docusaurus/sidebars.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ -const sidebars = { - tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], -}; - -module.exports = sidebars; diff --git a/docusaurus/src/components/HomepageFeatures/index.js b/docusaurus/src/components/HomepageFeatures/index.js deleted file mode 100644 index 67ccdb668..000000000 --- a/docusaurus/src/components/HomepageFeatures/index.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; -import styles from './styles.module.css'; - -const FeatureList = [ - { - title: 'End-to-End Encryption', - description: ( - <> - RSA-4096 encryption with a private Certificate Authority. Your secrets are encrypted at rest - and only decryptable with your master password. Zero-knowledge architecture. - - ), - }, - { - title: 'Team Sharing & Applications', - description: ( - <> - Share secrets with Nextcloud users and groups. Register applications with CSR-based - onboarding. Write-without-read enables secure credential provisioning. - - ), - }, - { - title: 'Nextcloud-Native', - description: ( - <> - Built on Nextcloud users, groups, notifications, and unified search. No external - dependencies. Self-hosted, sovereign, and fully integrated with your collaboration platform. - - ), - }, -]; - -function Feature({title, description}) { - return ( -
-
-

{title}

-

{description}

-
-
- ); -} - -export default function HomepageFeatures() { - return ( -
-
-
- {FeatureList.map((props, idx) => ( - - ))} -
-
-
- ); -} diff --git a/docusaurus/src/components/HomepageFeatures/styles.module.css b/docusaurus/src/components/HomepageFeatures/styles.module.css deleted file mode 100644 index af30b0ff8..000000000 --- a/docusaurus/src/components/HomepageFeatures/styles.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.features { - display: flex; - align-items: center; - padding: 2rem 0; - width: 100%; -} \ No newline at end of file diff --git a/docusaurus/src/css/custom.css b/docusaurus/src/css/custom.css deleted file mode 100644 index dfaf54c0e..000000000 --- a/docusaurus/src/css/custom.css +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Any CSS included here will be global. The classic template - * bundles Infima by default. Infima is a CSS framework designed to - * work well for content-first websites. - */ - -/* Import Poppins font from Google Fonts */ -@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap'); - -/* You can override the default Infima variables here. */ -:root { - /* Primary color: Conduction blue (matching hex logo) */ - --ifm-color-primary: #4376FC; - --ifm-color-primary-dark: #2460fb; - --ifm-color-primary-darker: #1555fb; - --ifm-color-primary-darkest: #0343e4; - --ifm-color-primary-light: #628cfb; - --ifm-color-primary-lighter: #7198fc; - --ifm-color-primary-lightest: #9fb8fd; - - /* Typography settings */ - --ifm-font-family-base: 'Poppins', system-ui, -apple-system, sans-serif; - --ifm-heading-font-family: 'Poppins', system-ui, -apple-system, sans-serif; - --ifm-font-weight-semibold: 600; - --ifm-heading-font-weight: 600; - --ifm-h1-font-size: 2.5rem; - --ifm-h2-font-size: 2rem; - --ifm-h3-font-size: 1.5rem; - --ifm-h4-font-size: 1.25rem; - - /* Code settings */ - --ifm-code-font-size: 95%; - --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); -} - -/* Dark mode color palette */ -[data-theme='dark'] { - /* Primary colors */ - --ifm-color-primary: #628cfb; - --ifm-color-primary-dark: #4376FC; - --ifm-color-primary-darker: #3469fb; - --ifm-color-primary-darkest: #1555fb; - --ifm-color-primary-light: #7b9efc; - --ifm-color-primary-lighter: #8aa9fd; - --ifm-color-primary-lightest: #b1c5fd; - --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); - - /* Background colors */ - --ifm-background-color: #1e1e1e; - --ifm-background-surface-color: #242526; - - /* Text colors */ - --ifm-font-color-base: #e5e5e5; - --ifm-heading-color: #ffffff; - --ifm-color-content: #e5e5e5; - --ifm-color-content-secondary: #b0b0b0; - - /* Navbar */ - --ifm-navbar-background-color: #242526; - --ifm-navbar-link-color: #e5e5e5; - --ifm-navbar-link-hover-color: #628cfb; - - /* Sidebar */ - --ifm-sidebar-background-color: #1e1e1e; - --ifm-menu-color: #e5e5e5; - --ifm-menu-color-active: #628cfb; - - /* Code blocks */ - --ifm-code-background: rgba(0, 0, 0, 0.3); - --ifm-code-color: #e5e5e5; - - /* Tables */ - --ifm-table-border-color: #3a3a3a; - --ifm-table-stripe-background: rgba(255, 255, 255, 0.05); - - /* Cards */ - --ifm-card-background-color: #242526; - --ifm-card-border-color: #3a3a3a; - - /* Performance optimizations */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* Typography adjustments */ -.markdown { - font-weight: 400; - line-height: 1.8; -} - -.markdown h1, .markdown h2, .markdown h3, .markdown h4 { - margin-top: 2rem; - margin-bottom: 1rem; - font-weight: 600; -} - -/* Navbar adjustments */ -.navbar { - font-weight: 500; -} - -/* Sidebar adjustments */ -.menu { - font-weight: 400; -} - -/* Smooth transitions for theme switching */ -html { - transition: background-color 0.2s ease, color 0.2s ease; -} - -/* Reduce motion for accessibility */ -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} diff --git a/docusaurus/src/pages/index.js b/docusaurus/src/pages/index.js deleted file mode 100644 index 45aafbb90..000000000 --- a/docusaurus/src/pages/index.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; -import Link from '@docusaurus/Link'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import Layout from '@theme/Layout'; -import HomepageFeatures from '@site/src/components/HomepageFeatures'; - -import styles from './index.module.css'; - -function HomepageHeader() { - const {siteConfig} = useDocusaurusContext(); - return ( -
-
-

{siteConfig.title}

-

{siteConfig.tagline}

-
- - Documentation - -
-
-
- ); -} - -export default function Home() { - const {siteConfig} = useDocusaurusContext(); - return ( - - -
- -
-
- ); -} diff --git a/docusaurus/src/pages/index.module.css b/docusaurus/src/pages/index.module.css deleted file mode 100644 index 861f33611..000000000 --- a/docusaurus/src/pages/index.module.css +++ /dev/null @@ -1,18 +0,0 @@ -.heroBanner { - padding: 4rem 0; - text-align: center; - position: relative; - overflow: hidden; -} - -@media screen and (max-width: 996px) { - .heroBanner { - padding: 2rem; - } -} - -.buttons { - display: flex; - align-items: center; - justify-content: center; -} \ No newline at end of file diff --git a/docusaurus/static/CNAME b/docusaurus/static/CNAME deleted file mode 100644 index 08eb25ad4..000000000 --- a/docusaurus/static/CNAME +++ /dev/null @@ -1 +0,0 @@ -keepiq.app diff --git a/docusaurus/static/img/logo.svg b/docusaurus/static/img/logo.svg deleted file mode 100644 index 168f9ec2c..000000000 --- a/docusaurus/static/img/logo.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/l10n/be.js b/l10n/be.js index 3642e3ce2..50406b31e 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,43 @@ 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)": "Лічыць сакрэты набору скампраметаванымі (пазначыць для ратацыі і апавясціць уладальнікаў)", + "%n secret could not be decrypted and is not in this export.": "%n сакрэт не ўдалося расшыфраваць, і яго няма ў гэтым экспарце.", + "%n secrets could not be decrypted and are not in this export.": "%n сакрэтаў не ўдалося расшыфраваць, і іх няма ў гэтым экспарце.", + "Continue without the secrets that could not be decrypted": "Працягнуць без сакрэтаў, якія не ўдалося расшыфраваць", + "This request is no longer available.": "Гэты запыт больш не даступны.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Выберыце, якія экстраныя кантакты могуць атрымаць ваш новы ключ. Адзначайце толькі людзей, якіх вы прызначылі самі і якім па-ранейшаму давяраеце: той, хто меў вашу сесію, мог дадаць уласны кантакт. Неадзначаныя кантакты губляюць экстраны доступ; пазней вы можаце прызначыць іх зноў.", + "{grantee}, waiting period in days: {days}": "{grantee}, перыяд чакання ў днях: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Вы не пацвердзілі гэтыя кантакты, таму іх экстраны доступ выдалены. Прызначайце іх зноў, толькі калі ўпэўнены, што дадалі іх самі.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Гэтыя кантакты мелі чаканы або ўхвалены запыт на экстраны доступ, таму не атрымалі ваш новы ключ. Менавіта так выглядаў бы кантакт, дададзены кімсьці іншым: не прызначайце іх зноў, калі не ведаеце, што запыт быў сапраўдным.", + "Invalidated": "Ануляваны", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Гэты кантакт меў чаканы або ўхвалены запыт на экстраны доступ, калі вы змянілі ключ, таму не атрымаў ваш новы ключ. Менавіта так выглядаў бы кантакт, дададзены кімсьці іншым: не прызначайце яго зноў, калі не ведаеце, што запыт быў сапраўдным.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ратацыя ключа была адноўлена, таму гэтыя экстраныя кантакты не ўдалося перанесці, і іх надзвычайны доступ выдалены. Дадайце іх зноў у раздзеле «Надзвычайны доступ», калі яны вам яшчэ патрэбныя.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ратацыя ключа выдаліла %n экстраны кантакт. Праверце «Надзвычайны доступ» і дадайце яго зноў, калі ён вам яшчэ патрэбны.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ратацыя ключа выдаліла %n экстраных кантактаў. Праверце «Надзвычайны доступ» і дадайце іх зноў, калі яны вам яшчэ патрэбныя.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ратацыя ключа выдаліла надзвычайны доступ гэтага кантакту. Прызначце яго зноў, калі ён вам яшчэ патрэбны." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/be.json b/l10n/be.json index afb9135e9..0c3c43f17 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,43 @@ "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)": "Лічыць сакрэты набору скампраметаванымі (пазначыць для ратацыі і апавясціць уладальнікаў)", + "%n secret could not be decrypted and is not in this export.": "%n сакрэт не ўдалося расшыфраваць, і яго няма ў гэтым экспарце.", + "%n secrets could not be decrypted and are not in this export.": "%n сакрэтаў не ўдалося расшыфраваць, і іх няма ў гэтым экспарце.", + "Continue without the secrets that could not be decrypted": "Працягнуць без сакрэтаў, якія не ўдалося расшыфраваць", + "This request is no longer available.": "Гэты запыт больш не даступны.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Выберыце, якія экстраныя кантакты могуць атрымаць ваш новы ключ. Адзначайце толькі людзей, якіх вы прызначылі самі і якім па-ранейшаму давяраеце: той, хто меў вашу сесію, мог дадаць уласны кантакт. Неадзначаныя кантакты губляюць экстраны доступ; пазней вы можаце прызначыць іх зноў.", + "{grantee}, waiting period in days: {days}": "{grantee}, перыяд чакання ў днях: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Вы не пацвердзілі гэтыя кантакты, таму іх экстраны доступ выдалены. Прызначайце іх зноў, толькі калі ўпэўнены, што дадалі іх самі.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Гэтыя кантакты мелі чаканы або ўхвалены запыт на экстраны доступ, таму не атрымалі ваш новы ключ. Менавіта так выглядаў бы кантакт, дададзены кімсьці іншым: не прызначайце іх зноў, калі не ведаеце, што запыт быў сапраўдным.", + "Invalidated": "Ануляваны", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Гэты кантакт меў чаканы або ўхвалены запыт на экстраны доступ, калі вы змянілі ключ, таму не атрымаў ваш новы ключ. Менавіта так выглядаў бы кантакт, дададзены кімсьці іншым: не прызначайце яго зноў, калі не ведаеце, што запыт быў сапраўдным.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ратацыя ключа была адноўлена, таму гэтыя экстраныя кантакты не ўдалося перанесці, і іх надзвычайны доступ выдалены. Дадайце іх зноў у раздзеле «Надзвычайны доступ», калі яны вам яшчэ патрэбныя.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ратацыя ключа выдаліла %n экстраны кантакт. Праверце «Надзвычайны доступ» і дадайце яго зноў, калі ён вам яшчэ патрэбны.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ратацыя ключа выдаліла %n экстраных кантактаў. Праверце «Надзвычайны доступ» і дадайце іх зноў, калі яны вам яшчэ патрэбныя.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ратацыя ключа выдаліла надзвычайны доступ гэтага кантакту. Прызначце яго зноў, калі ён вам яшчэ патрэбны." }, "plurals": null } diff --git a/l10n/bg.js b/l10n/bg.js index 4585e96b0..d50004eed 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,43 @@ 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)": "Третирай тайните на комплекта като компрометирани (маркирай за ротация и уведоми собствениците)", + "%n secret could not be decrypted and is not in this export.": "%n тайна не можа да бъде дешифрирана и не е включена в този експорт.", + "%n secrets could not be decrypted and are not in this export.": "%n тайни не можаха да бъдат дешифрирани и не са включени в този експорт.", + "Continue without the secrets that could not be decrypted": "Продължаване без тайните, които не можаха да бъдат дешифрирани", + "This request is no longer available.": "Тази заявка вече не е налична.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Изберете кои контакти за спешни случаи могат да получат новия ви ключ. Отбележете само хора, които сте определили сами и на които все още имате доверие: който е държал сесията ви, може да е добавил свой контакт. Неотбелязаните контакти губят спешния си достъп; можете да ги определите отново след това.", + "{grantee}, waiting period in days: {days}": "{grantee}, период на изчакване в дни: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Не сте потвърдили тези контакти, затова спешният им достъп беше премахнат. Определете ги отново само ако сте сигурни, че сте ги добавили сами.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Тези контакти имаха чакаща или одобрена заявка за спешен достъп, затова не получиха новия ви ключ. Така би изглеждал контакт, добавен от някой друг: не ги определяйте отново, освен ако не знаете, че заявката е истинска.", + "Invalidated": "Обезсилено", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Този контакт имаше чакаща или одобрена заявка за спешен достъп, когато сменихте ключа си, затова не получи новия ви ключ. Така би изглеждал контакт, добавен от някой друг: не го определяйте отново, освен ако не знаете, че заявката е истинска.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацията на ключа беше възобновена, затова тези контакти за спешен достъп не можаха да бъдат пренесени и достъпът им при спешност беше премахнат. Добавете ги отново от „Достъп при спешност“, ако все още ги искате.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротацията на ключа премахна %n контакт за спешен достъп. Проверете „Достъп при спешност“ и го добавете отново, ако все още го искате.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротацията на ключа премахна %n контакта за спешен достъп. Проверете „Достъп при спешност“ и ги добавете отново, ако все още ги искате.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацията на ключа премахна достъпа при спешност на този контакт. Определете го отново, ако все още го искате." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index be7c09bb4..c28e98156 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,43 @@ "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)": "Третирай тайните на комплекта като компрометирани (маркирай за ротация и уведоми собствениците)", + "%n secret could not be decrypted and is not in this export.": "%n тайна не можа да бъде дешифрирана и не е включена в този експорт.", + "%n secrets could not be decrypted and are not in this export.": "%n тайни не можаха да бъдат дешифрирани и не са включени в този експорт.", + "Continue without the secrets that could not be decrypted": "Продължаване без тайните, които не можаха да бъдат дешифрирани", + "This request is no longer available.": "Тази заявка вече не е налична.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Изберете кои контакти за спешни случаи могат да получат новия ви ключ. Отбележете само хора, които сте определили сами и на които все още имате доверие: който е държал сесията ви, може да е добавил свой контакт. Неотбелязаните контакти губят спешния си достъп; можете да ги определите отново след това.", + "{grantee}, waiting period in days: {days}": "{grantee}, период на изчакване в дни: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Не сте потвърдили тези контакти, затова спешният им достъп беше премахнат. Определете ги отново само ако сте сигурни, че сте ги добавили сами.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Тези контакти имаха чакаща или одобрена заявка за спешен достъп, затова не получиха новия ви ключ. Така би изглеждал контакт, добавен от някой друг: не ги определяйте отново, освен ако не знаете, че заявката е истинска.", + "Invalidated": "Обезсилено", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Този контакт имаше чакаща или одобрена заявка за спешен достъп, когато сменихте ключа си, затова не получи новия ви ключ. Така би изглеждал контакт, добавен от някой друг: не го определяйте отново, освен ако не знаете, че заявката е истинска.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацията на ключа беше възобновена, затова тези контакти за спешен достъп не можаха да бъдат пренесени и достъпът им при спешност беше премахнат. Добавете ги отново от „Достъп при спешност“, ако все още ги искате.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротацията на ключа премахна %n контакт за спешен достъп. Проверете „Достъп при спешност“ и го добавете отново, ако все още го искате.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротацията на ключа премахна %n контакта за спешен достъп. Проверете „Достъп при спешност“ и ги добавете отново, ако все още ги искате.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацията на ключа премахна достъпа при спешност на този контакт. Определете го отново, ако все още го искате." }, "plurals": null } diff --git a/l10n/bs.js b/l10n/bs.js index 800559431..c636188c9 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n tajna nije mogla biti dešifrovana i nije u ovom izvozu.", + "%n secrets could not be decrypted and are not in this export.": "%n tajni nije moglo biti dešifrovano i nisu u ovom izvozu.", + "Continue without the secrets that could not be decrypted": "Nastavi bez tajni koje nije bilo moguće dešifrovati", + "This request is no longer available.": "Ovaj zahtjev više nije dostupan.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Odaberite koji kontakti za hitne slučajeve smiju primiti vaš novi ključ. Označite samo osobe koje ste sami odredili i kojima i dalje vjerujete: ko je imao vašu sesiju, mogao je dodati vlastiti kontakt. Neoznačeni kontakti gube pristup u hitnim slučajevima; kasnije ih možete ponovo odrediti.", + "{grantee}, waiting period in days: {days}": "{grantee}, period čekanja u danima: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Niste potvrdili ove kontakte pa je njihov pristup u hitnim slučajevima uklonjen. Ponovo ih odredite samo ako ste sigurni da ste ih sami dodali.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ovi kontakti su imali zahtjev za pristup u hitnim slučajevima na čekanju ili odobren pa nisu dobili vaš novi ključ. Tako bi izgledao kontakt koji je dodao neko drugi: ne određujte ih ponovo osim ako znate da je zahtjev bio stvaran.", + "Invalidated": "Poništeno", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ovaj kontakt je imao zahtjev za pristup u hitnim slučajevima na čekanju ili odobren kada ste promijenili ključ pa nije dobio vaš novi ključ. Tako bi izgledao kontakt koji je dodao neko drugi: ne određujte ga ponovo osim ako znate da je zahtjev bio stvaran.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa je nastavljena pa ovi kontakti za pristup u nuždi nisu mogli biti preneseni i njihov pristup u nuždi je uklonjen. Dodajte ih ponovo u odjeljku Pristup u nuždi ako ih još želite.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa je uklonila %n kontakt za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ga ponovo ako ga još želite.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa je uklonila %n kontakta za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ih ponovo ako ih još želite.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je uklonila pristup u nuždi ovog kontakta. Odredite ga ponovo ako ga još želite." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.json b/l10n/bs.json index 0948488a2..288050ebe 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n tajna nije mogla biti dešifrovana i nije u ovom izvozu.", + "%n secrets could not be decrypted and are not in this export.": "%n tajni nije moglo biti dešifrovano i nisu u ovom izvozu.", + "Continue without the secrets that could not be decrypted": "Nastavi bez tajni koje nije bilo moguće dešifrovati", + "This request is no longer available.": "Ovaj zahtjev više nije dostupan.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Odaberite koji kontakti za hitne slučajeve smiju primiti vaš novi ključ. Označite samo osobe koje ste sami odredili i kojima i dalje vjerujete: ko je imao vašu sesiju, mogao je dodati vlastiti kontakt. Neoznačeni kontakti gube pristup u hitnim slučajevima; kasnije ih možete ponovo odrediti.", + "{grantee}, waiting period in days: {days}": "{grantee}, period čekanja u danima: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Niste potvrdili ove kontakte pa je njihov pristup u hitnim slučajevima uklonjen. Ponovo ih odredite samo ako ste sigurni da ste ih sami dodali.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ovi kontakti su imali zahtjev za pristup u hitnim slučajevima na čekanju ili odobren pa nisu dobili vaš novi ključ. Tako bi izgledao kontakt koji je dodao neko drugi: ne određujte ih ponovo osim ako znate da je zahtjev bio stvaran.", + "Invalidated": "Poništeno", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ovaj kontakt je imao zahtjev za pristup u hitnim slučajevima na čekanju ili odobren kada ste promijenili ključ pa nije dobio vaš novi ključ. Tako bi izgledao kontakt koji je dodao neko drugi: ne određujte ga ponovo osim ako znate da je zahtjev bio stvaran.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa je nastavljena pa ovi kontakti za pristup u nuždi nisu mogli biti preneseni i njihov pristup u nuždi je uklonjen. Dodajte ih ponovo u odjeljku Pristup u nuždi ako ih još želite.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa je uklonila %n kontakt za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ga ponovo ako ga još želite.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa je uklonila %n kontakta za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ih ponovo ako ih još želite.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je uklonila pristup u nuždi ovog kontakta. Odredite ga ponovo ako ga još želite." }, "plurals": null } diff --git a/l10n/ca.js b/l10n/ca.js index 989b0eab3..729e795b4 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n secret no s'ha pogut desxifrar i no és en aquesta exportació.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets no s'han pogut desxifrar i no són en aquesta exportació.", + "Continue without the secrets that could not be decrypted": "Continua sense els secrets que no s'han pogut desxifrar", + "This request is no longer available.": "Aquesta sol·licitud ja no està disponible.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Trieu quins contactes d'emergència poden rebre la vostra clau nova. Marqueu només persones que heu designat vosaltres i en qui encara confieu: qui tenia la vostra sessió pot haver afegit un contacte propi. Els contactes que no marqueu perden l'accés d'emergència; després els podeu tornar a designar.", + "{grantee}, waiting period in days: {days}": "{grantee}, període d’espera en dies: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "No has confirmat aquests contactes, per tant se n'ha eliminat l'accés d'emergència. Torna'ls a designar només si estàs segur que els has afegit tu.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Aquests contactes tenien una sol·licitud d'accés d'emergència pendent o aprovada, per tant no han rebut la teva clau nova. Així és com es veuria un contacte afegit per una altra persona: no els tornis a designar tret que sàpigues que la sol·licitud era legítima.", + "Invalidated": "Invalidat", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Aquest contacte tenia una sol·licitud d'accés d'emergència pendent o aprovada quan vas canviar la clau, per tant no ha rebut la teva clau nova. Així és com es veuria un contacte afegit per una altra persona: no el tornis a designar tret que sàpigues que la sol·licitud era legítima.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "La rotació de claus s'ha reprès, per tant aquests contactes d'emergència no s'han pogut traspassar i se'ls ha retirat l'accés d'emergència. Torna'ls a afegir des d'Accés d'emergència si encara els vols.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "La rotació de claus ha retirat %n contacte d'emergència. Revisa Accés d'emergència i torna'l a afegir si encara el vols.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "La rotació de claus ha retirat %n contactes d'emergència. Revisa Accés d'emergència i torna'ls a afegir si encara els vols.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotació de claus ha retirat l'accés d'emergència d'aquest contacte. Torna'l a designar si encara el vols." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index 6f18eb182..e60e36388 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n secret no s'ha pogut desxifrar i no és en aquesta exportació.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets no s'han pogut desxifrar i no són en aquesta exportació.", + "Continue without the secrets that could not be decrypted": "Continua sense els secrets que no s'han pogut desxifrar", + "This request is no longer available.": "Aquesta sol·licitud ja no està disponible.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Trieu quins contactes d'emergència poden rebre la vostra clau nova. Marqueu només persones que heu designat vosaltres i en qui encara confieu: qui tenia la vostra sessió pot haver afegit un contacte propi. Els contactes que no marqueu perden l'accés d'emergència; després els podeu tornar a designar.", + "{grantee}, waiting period in days: {days}": "{grantee}, període d’espera en dies: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "No has confirmat aquests contactes, per tant se n'ha eliminat l'accés d'emergència. Torna'ls a designar només si estàs segur que els has afegit tu.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Aquests contactes tenien una sol·licitud d'accés d'emergència pendent o aprovada, per tant no han rebut la teva clau nova. Així és com es veuria un contacte afegit per una altra persona: no els tornis a designar tret que sàpigues que la sol·licitud era legítima.", + "Invalidated": "Invalidat", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Aquest contacte tenia una sol·licitud d'accés d'emergència pendent o aprovada quan vas canviar la clau, per tant no ha rebut la teva clau nova. Així és com es veuria un contacte afegit per una altra persona: no el tornis a designar tret que sàpigues que la sol·licitud era legítima.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "La rotació de claus s'ha reprès, per tant aquests contactes d'emergència no s'han pogut traspassar i se'ls ha retirat l'accés d'emergència. Torna'ls a afegir des d'Accés d'emergència si encara els vols.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "La rotació de claus ha retirat %n contacte d'emergència. Revisa Accés d'emergència i torna'l a afegir si encara el vols.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "La rotació de claus ha retirat %n contactes d'emergència. Revisa Accés d'emergència i torna'ls a afegir si encara els vols.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotació de claus ha retirat l'accés d'emergència d'aquest contacte. Torna'l a designar si encara el vols." }, "plurals": null } diff --git a/l10n/cs.js b/l10n/cs.js index 5ecd154d8..3ee623003 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n tajemství nebylo možné dešifrovat a není v tomto exportu.", + "%n secrets could not be decrypted and are not in this export.": "%n tajemství nebylo možné dešifrovat a nejsou v tomto exportu.", + "Continue without the secrets that could not be decrypted": "Pokračovat bez tajemství, která nebylo možné dešifrovat", + "This request is no longer available.": "Tato žádost již není k dispozici.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Vyberte, které nouzové kontakty mohou dostat váš nový klíč. Zaškrtněte jen osoby, které jste určili sami a kterým stále důvěřujete: kdo měl vaši relaci, mohl přidat vlastní kontakt. Nezaškrtnuté kontakty ztratí nouzový přístup; později je můžete určit znovu.", + "{grantee}, waiting period in days: {days}": "{grantee}, čekací doba ve dnech: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Tyto kontakty jste nepotvrdili, a proto byl jejich nouzový přístup odebrán. Určete je znovu jen tehdy, pokud jste si jisti, že jste je přidali sami.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Tyto kontakty měly čekající nebo schválenou žádost o nouzový přístup, a proto nedostaly váš nový klíč. Takto by vypadal kontakt, který přidal někdo jiný: neurčujte je znovu, pokud nevíte, že žádost byla skutečná.", + "Invalidated": "Zneplatněno", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Tento kontakt měl při změně vašeho klíče čekající nebo schválenou žádost o nouzový přístup, a proto nedostal váš nový klíč. Takto by vypadal kontakt, který přidal někdo jiný: neurčujte jej znovu, pokud nevíte, že žádost byla skutečná.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotace klíče byla obnovena, a proto tyto nouzové kontakty nebylo možné převést a jejich přístup pro naléhavé případy byl odebrán. Pokud je stále chcete, přidejte je znovu v části Přístup pro naléhavé případy.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotace klíče odebrala %n nouzový kontakt. Zkontrolujte Přístup pro naléhavé případy a přidejte jej znovu, pokud jej stále chcete.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotace klíče odebrala %n nouzových kontaktů. Zkontrolujte Přístup pro naléhavé případy a přidejte je znovu, pokud je stále chcete.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotace klíče odebrala tomuto kontaktu přístup pro naléhavé případy. Pokud jej stále chcete, určete jej znovu." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.json b/l10n/cs.json index 67f32e1bf..2102e8a3a 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n tajemství nebylo možné dešifrovat a není v tomto exportu.", + "%n secrets could not be decrypted and are not in this export.": "%n tajemství nebylo možné dešifrovat a nejsou v tomto exportu.", + "Continue without the secrets that could not be decrypted": "Pokračovat bez tajemství, která nebylo možné dešifrovat", + "This request is no longer available.": "Tato žádost již není k dispozici.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Vyberte, které nouzové kontakty mohou dostat váš nový klíč. Zaškrtněte jen osoby, které jste určili sami a kterým stále důvěřujete: kdo měl vaši relaci, mohl přidat vlastní kontakt. Nezaškrtnuté kontakty ztratí nouzový přístup; později je můžete určit znovu.", + "{grantee}, waiting period in days: {days}": "{grantee}, čekací doba ve dnech: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Tyto kontakty jste nepotvrdili, a proto byl jejich nouzový přístup odebrán. Určete je znovu jen tehdy, pokud jste si jisti, že jste je přidali sami.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Tyto kontakty měly čekající nebo schválenou žádost o nouzový přístup, a proto nedostaly váš nový klíč. Takto by vypadal kontakt, který přidal někdo jiný: neurčujte je znovu, pokud nevíte, že žádost byla skutečná.", + "Invalidated": "Zneplatněno", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Tento kontakt měl při změně vašeho klíče čekající nebo schválenou žádost o nouzový přístup, a proto nedostal váš nový klíč. Takto by vypadal kontakt, který přidal někdo jiný: neurčujte jej znovu, pokud nevíte, že žádost byla skutečná.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotace klíče byla obnovena, a proto tyto nouzové kontakty nebylo možné převést a jejich přístup pro naléhavé případy byl odebrán. Pokud je stále chcete, přidejte je znovu v části Přístup pro naléhavé případy.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotace klíče odebrala %n nouzový kontakt. Zkontrolujte Přístup pro naléhavé případy a přidejte jej znovu, pokud jej stále chcete.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotace klíče odebrala %n nouzových kontaktů. Zkontrolujte Přístup pro naléhavé případy a přidejte je znovu, pokud je stále chcete.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotace klíče odebrala tomuto kontaktu přístup pro naléhavé případy. Pokud jej stále chcete, určete jej znovu." }, "plurals": null } diff --git a/l10n/da.js b/l10n/da.js index 0b9175021..e01e89365 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n hemmelighed kunne ikke dekrypteres og er ikke med i denne eksport.", + "%n secrets could not be decrypted and are not in this export.": "%n hemmeligheder kunne ikke dekrypteres og er ikke med i denne eksport.", + "Continue without the secrets that could not be decrypted": "Fortsæt uden de hemmeligheder, der ikke kunne dekrypteres", + "This request is no longer available.": "Denne anmodning er ikke længere tilgængelig.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Vælg hvilke nødkontakter der må modtage din nye nøgle. Sæt kun flueben ved personer, du selv har udpeget og stadig stoler på: den, der havde din session, kan have tilføjet sin egen kontakt. Kontakter uden flueben mister deres nødadgang; du kan udpege dem igen bagefter.", + "{grantee}, waiting period in days: {days}": "{grantee}, ventetid i dage: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Du bekræftede ikke disse kontakter, så deres nødadgang er fjernet. Udpeg dem kun igen, hvis du er sikker på, at du selv har tilføjet dem.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Disse kontakter havde en afventende eller godkendt anmodning om nødadgang, så de fik ikke din nye nøgle. Sådan ville en kontakt tilføjet af en anden se ud: udpeg dem ikke igen, medmindre du ved, at anmodningen var ægte.", + "Invalidated": "Ugyldiggjort", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Denne kontakt havde en afventende eller godkendt anmodning om nødadgang, da du skiftede nøgle, så den fik ikke din nye nøgle. Sådan ville en kontakt tilføjet af en anden se ud: udpeg den ikke igen, medmindre du ved, at anmodningen var ægte.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Din nøglerotation blev genoptaget, så disse nødkontakter kunne ikke overføres, og deres nødadgang blev fjernet. Tilføj dem igen under Nødadgang, hvis du stadig ønsker dem.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Din nøglerotation fjernede %n nødkontakt. Tjek Nødadgang, og tilføj den igen, hvis du stadig ønsker den.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Din nøglerotation fjernede %n nødkontakter. Tjek Nødadgang, og tilføj dem igen, hvis du stadig ønsker dem.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nøglerotation fjernede denne kontakts nødadgang. Udpeg den igen, hvis du stadig ønsker den." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 9012c3db3..5c843470c 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n hemmelighed kunne ikke dekrypteres og er ikke med i denne eksport.", + "%n secrets could not be decrypted and are not in this export.": "%n hemmeligheder kunne ikke dekrypteres og er ikke med i denne eksport.", + "Continue without the secrets that could not be decrypted": "Fortsæt uden de hemmeligheder, der ikke kunne dekrypteres", + "This request is no longer available.": "Denne anmodning er ikke længere tilgængelig.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Vælg hvilke nødkontakter der må modtage din nye nøgle. Sæt kun flueben ved personer, du selv har udpeget og stadig stoler på: den, der havde din session, kan have tilføjet sin egen kontakt. Kontakter uden flueben mister deres nødadgang; du kan udpege dem igen bagefter.", + "{grantee}, waiting period in days: {days}": "{grantee}, ventetid i dage: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Du bekræftede ikke disse kontakter, så deres nødadgang er fjernet. Udpeg dem kun igen, hvis du er sikker på, at du selv har tilføjet dem.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Disse kontakter havde en afventende eller godkendt anmodning om nødadgang, så de fik ikke din nye nøgle. Sådan ville en kontakt tilføjet af en anden se ud: udpeg dem ikke igen, medmindre du ved, at anmodningen var ægte.", + "Invalidated": "Ugyldiggjort", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Denne kontakt havde en afventende eller godkendt anmodning om nødadgang, da du skiftede nøgle, så den fik ikke din nye nøgle. Sådan ville en kontakt tilføjet af en anden se ud: udpeg den ikke igen, medmindre du ved, at anmodningen var ægte.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Din nøglerotation blev genoptaget, så disse nødkontakter kunne ikke overføres, og deres nødadgang blev fjernet. Tilføj dem igen under Nødadgang, hvis du stadig ønsker dem.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Din nøglerotation fjernede %n nødkontakt. Tjek Nødadgang, og tilføj den igen, hvis du stadig ønsker den.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Din nøglerotation fjernede %n nødkontakter. Tjek Nødadgang, og tilføj dem igen, hvis du stadig ønsker dem.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nøglerotation fjernede denne kontakts nødadgang. Udpeg den igen, hvis du stadig ønsker den." }, "plurals": null } diff --git a/l10n/de.js b/l10n/de.js index ab0178391..5e7448c14 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n Geheimnis konnte nicht entschlüsselt werden und ist nicht in diesem Export enthalten.", + "%n secrets could not be decrypted and are not in this export.": "%n Geheimnisse konnten nicht entschlüsselt werden und sind nicht in diesem Export enthalten.", + "Continue without the secrets that could not be decrypted": "Ohne die Geheimnisse fortfahren, die nicht entschlüsselt werden konnten", + "This request is no longer available.": "Diese Anfrage ist nicht mehr verfügbar.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Wählen Sie, welche Notfallkontakte Ihren neuen Schlüssel erhalten dürfen. Markieren Sie nur Personen, die Sie selbst benannt haben und denen Sie weiterhin vertrauen: Wer Ihre Sitzung in der Hand hatte, könnte einen eigenen Kontakt hinzugefügt haben. Nicht markierte Kontakte verlieren ihren Notfallzugang; Sie können sie danach erneut benennen.", + "{grantee}, waiting period in days: {days}": "{grantee}, Wartezeit in Tagen: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Sie haben diese Kontakte nicht bestätigt, daher wurde ihr Notfallzugang entfernt. Benennen Sie sie nur erneut, wenn Sie sicher sind, dass Sie sie selbst hinzugefügt haben.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Für diese Kontakte war eine Anfrage auf Notfallzugang offen oder bereits genehmigt, daher haben sie Ihren neuen Schlüssel nicht erhalten. So sähe ein Kontakt aus, den jemand anderes hinzugefügt hat: Benennen Sie sie nicht erneut, es sei denn, Sie wissen, dass die Anfrage echt war.", + "Invalidated": "Ungültig geworden", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Für diesen Kontakt war beim Wechsel Ihres Schlüssels eine Anfrage auf Notfallzugang offen oder bereits genehmigt, daher hat er Ihren neuen Schlüssel nicht erhalten. So sähe ein Kontakt aus, den jemand anderes hinzugefügt hat: Benennen Sie ihn nicht erneut, es sei denn, Sie wissen, dass die Anfrage echt war.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ihre Schlüsselrotation wurde fortgesetzt, daher konnten diese Notfallkontakte nicht übernommen werden und ihr Notfallzugriff wurde entfernt. Fügen Sie sie unter Notfallzugriff erneut hinzu, wenn Sie sie noch möchten.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ihre Schlüsselrotation hat %n Notfallkontakt entfernt. Prüfen Sie den Notfallzugriff und fügen Sie ihn erneut hinzu, wenn Sie ihn noch möchten.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ihre Schlüsselrotation hat %n Notfallkontakte entfernt. Prüfen Sie den Notfallzugriff und fügen Sie sie erneut hinzu, wenn Sie sie noch möchten.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ihre Schlüsselrotation hat den Notfallzugriff dieses Kontakts entfernt. Benennen Sie ihn erneut, wenn Sie ihn noch möchten." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index 69ce8fa38..0fe008b04 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n Geheimnis konnte nicht entschlüsselt werden und ist nicht in diesem Export enthalten.", + "%n secrets could not be decrypted and are not in this export.": "%n Geheimnisse konnten nicht entschlüsselt werden und sind nicht in diesem Export enthalten.", + "Continue without the secrets that could not be decrypted": "Ohne die Geheimnisse fortfahren, die nicht entschlüsselt werden konnten", + "This request is no longer available.": "Diese Anfrage ist nicht mehr verfügbar.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Wählen Sie, welche Notfallkontakte Ihren neuen Schlüssel erhalten dürfen. Markieren Sie nur Personen, die Sie selbst benannt haben und denen Sie weiterhin vertrauen: Wer Ihre Sitzung in der Hand hatte, könnte einen eigenen Kontakt hinzugefügt haben. Nicht markierte Kontakte verlieren ihren Notfallzugang; Sie können sie danach erneut benennen.", + "{grantee}, waiting period in days: {days}": "{grantee}, Wartezeit in Tagen: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Sie haben diese Kontakte nicht bestätigt, daher wurde ihr Notfallzugang entfernt. Benennen Sie sie nur erneut, wenn Sie sicher sind, dass Sie sie selbst hinzugefügt haben.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Für diese Kontakte war eine Anfrage auf Notfallzugang offen oder bereits genehmigt, daher haben sie Ihren neuen Schlüssel nicht erhalten. So sähe ein Kontakt aus, den jemand anderes hinzugefügt hat: Benennen Sie sie nicht erneut, es sei denn, Sie wissen, dass die Anfrage echt war.", + "Invalidated": "Ungültig geworden", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Für diesen Kontakt war beim Wechsel Ihres Schlüssels eine Anfrage auf Notfallzugang offen oder bereits genehmigt, daher hat er Ihren neuen Schlüssel nicht erhalten. So sähe ein Kontakt aus, den jemand anderes hinzugefügt hat: Benennen Sie ihn nicht erneut, es sei denn, Sie wissen, dass die Anfrage echt war.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ihre Schlüsselrotation wurde fortgesetzt, daher konnten diese Notfallkontakte nicht übernommen werden und ihr Notfallzugriff wurde entfernt. Fügen Sie sie unter Notfallzugriff erneut hinzu, wenn Sie sie noch möchten.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ihre Schlüsselrotation hat %n Notfallkontakt entfernt. Prüfen Sie den Notfallzugriff und fügen Sie ihn erneut hinzu, wenn Sie ihn noch möchten.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ihre Schlüsselrotation hat %n Notfallkontakte entfernt. Prüfen Sie den Notfallzugriff und fügen Sie sie erneut hinzu, wenn Sie sie noch möchten.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ihre Schlüsselrotation hat den Notfallzugriff dieses Kontakts entfernt. Benennen Sie ihn erneut, wenn Sie ihn noch möchten." }, "plurals": null } diff --git a/l10n/el.js b/l10n/el.js index 21934b32c..5b19d8f67 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,43 @@ 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)": "Αντιμετώπιση των μυστικών της σουίτας ως παραβιασμένων (επισήμανση για εναλλαγή και ειδοποίηση ιδιοκτητών)", + "%n secret could not be decrypted and is not in this export.": "%n μυστικό δεν ήταν δυνατό να αποκρυπτογραφηθεί και δεν περιλαμβάνεται σε αυτή την εξαγωγή.", + "%n secrets could not be decrypted and are not in this export.": "%n μυστικά δεν ήταν δυνατό να αποκρυπτογραφηθούν και δεν περιλαμβάνονται σε αυτή την εξαγωγή.", + "Continue without the secrets that could not be decrypted": "Συνέχεια χωρίς τα μυστικά που δεν ήταν δυνατό να αποκρυπτογραφηθούν", + "This request is no longer available.": "Αυτό το αίτημα δεν είναι πλέον διαθέσιμο.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Επιλέξτε ποιες επαφές έκτακτης ανάγκης μπορούν να λάβουν το νέο σας κλειδί. Επιλέξτε μόνο άτομα που ορίσατε εσείς και εξακολουθείτε να εμπιστεύεστε: όποιος είχε την περίοδο σύνδεσής σας μπορεί να πρόσθεσε δική του επαφή. Οι επαφές που δεν επιλέγετε χάνουν την πρόσβαση έκτακτης ανάγκης· μπορείτε να τις ορίσετε ξανά αργότερα.", + "{grantee}, waiting period in days: {days}": "{grantee}, περίοδος αναμονής σε ημέρες: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Δεν επιβεβαιώσατε αυτές τις επαφές, οπότε η πρόσβαση έκτακτης ανάγκης τους αφαιρέθηκε. Ορίστε τις ξανά μόνο αν είστε βέβαιοι ότι τις προσθέσατε εσείς.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Αυτές οι επαφές είχαν εκκρεμές ή εγκεκριμένο αίτημα πρόσβασης έκτακτης ανάγκης, οπότε δεν έλαβαν το νέο σας κλειδί. Έτσι θα έμοιαζε μια επαφή που πρόσθεσε κάποιος άλλος: μην τις ορίσετε ξανά, εκτός αν ξέρετε ότι το αίτημα ήταν γνήσιο.", + "Invalidated": "Ακυρώθηκε", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Αυτή η επαφή είχε εκκρεμές ή εγκεκριμένο αίτημα πρόσβασης έκτακτης ανάγκης όταν αλλάξατε το κλειδί σας, οπότε δεν έλαβε το νέο σας κλειδί. Έτσι θα έμοιαζε μια επαφή που πρόσθεσε κάποιος άλλος: μην την ορίσετε ξανά, εκτός αν ξέρετε ότι το αίτημα ήταν γνήσιο.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Η εναλλαγή κλειδιού συνεχίστηκε, οπότε αυτές οι επαφές έκτακτης ανάγκης δεν μπόρεσαν να μεταφερθούν και η πρόσβασή τους έκτακτης ανάγκης αφαιρέθηκε. Προσθέστε τις ξανά από την Πρόσβαση έκτακτης ανάγκης, αν τις θέλετε ακόμα.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Η εναλλαγή κλειδιού αφαίρεσε %n επαφή έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε την ξανά, αν τη θέλετε ακόμα.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε %n επαφές έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε τις ξανά, αν τις θέλετε ακόμα.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε την πρόσβαση έκτακτης ανάγκης αυτής της επαφής. Ορίστε την ξανά, αν τη θέλετε ακόμα." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index f20cdbedc..c92d68429 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,43 @@ "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)": "Αντιμετώπιση των μυστικών της σουίτας ως παραβιασμένων (επισήμανση για εναλλαγή και ειδοποίηση ιδιοκτητών)", + "%n secret could not be decrypted and is not in this export.": "%n μυστικό δεν ήταν δυνατό να αποκρυπτογραφηθεί και δεν περιλαμβάνεται σε αυτή την εξαγωγή.", + "%n secrets could not be decrypted and are not in this export.": "%n μυστικά δεν ήταν δυνατό να αποκρυπτογραφηθούν και δεν περιλαμβάνονται σε αυτή την εξαγωγή.", + "Continue without the secrets that could not be decrypted": "Συνέχεια χωρίς τα μυστικά που δεν ήταν δυνατό να αποκρυπτογραφηθούν", + "This request is no longer available.": "Αυτό το αίτημα δεν είναι πλέον διαθέσιμο.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Επιλέξτε ποιες επαφές έκτακτης ανάγκης μπορούν να λάβουν το νέο σας κλειδί. Επιλέξτε μόνο άτομα που ορίσατε εσείς και εξακολουθείτε να εμπιστεύεστε: όποιος είχε την περίοδο σύνδεσής σας μπορεί να πρόσθεσε δική του επαφή. Οι επαφές που δεν επιλέγετε χάνουν την πρόσβαση έκτακτης ανάγκης· μπορείτε να τις ορίσετε ξανά αργότερα.", + "{grantee}, waiting period in days: {days}": "{grantee}, περίοδος αναμονής σε ημέρες: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Δεν επιβεβαιώσατε αυτές τις επαφές, οπότε η πρόσβαση έκτακτης ανάγκης τους αφαιρέθηκε. Ορίστε τις ξανά μόνο αν είστε βέβαιοι ότι τις προσθέσατε εσείς.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Αυτές οι επαφές είχαν εκκρεμές ή εγκεκριμένο αίτημα πρόσβασης έκτακτης ανάγκης, οπότε δεν έλαβαν το νέο σας κλειδί. Έτσι θα έμοιαζε μια επαφή που πρόσθεσε κάποιος άλλος: μην τις ορίσετε ξανά, εκτός αν ξέρετε ότι το αίτημα ήταν γνήσιο.", + "Invalidated": "Ακυρώθηκε", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Αυτή η επαφή είχε εκκρεμές ή εγκεκριμένο αίτημα πρόσβασης έκτακτης ανάγκης όταν αλλάξατε το κλειδί σας, οπότε δεν έλαβε το νέο σας κλειδί. Έτσι θα έμοιαζε μια επαφή που πρόσθεσε κάποιος άλλος: μην την ορίσετε ξανά, εκτός αν ξέρετε ότι το αίτημα ήταν γνήσιο.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Η εναλλαγή κλειδιού συνεχίστηκε, οπότε αυτές οι επαφές έκτακτης ανάγκης δεν μπόρεσαν να μεταφερθούν και η πρόσβασή τους έκτακτης ανάγκης αφαιρέθηκε. Προσθέστε τις ξανά από την Πρόσβαση έκτακτης ανάγκης, αν τις θέλετε ακόμα.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Η εναλλαγή κλειδιού αφαίρεσε %n επαφή έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε την ξανά, αν τη θέλετε ακόμα.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε %n επαφές έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε τις ξανά, αν τις θέλετε ακόμα.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε την πρόσβαση έκτακτης ανάγκης αυτής της επαφής. Ορίστε την ξανά, αν τη θέλετε ακόμα." }, "plurals": null } diff --git a/l10n/en.js b/l10n/en.js index 5c2ad61bc..0d86028c6 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,48 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets could not be decrypted and are not in this export.", + "Continue without the secrets that could not be decrypted": "Continue without the secrets that could not be decrypted", + "This request is no longer available.": "This request is no longer available.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.", + "{grantee}, waiting period in days: {days}": "{grantee}, waiting period in days: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.", + "Invalidated": "Invalidated", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Your key rotation removed this contact's emergency access. Designate them again if you still want them." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index ceaebde6d..80c5bbbf7 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,48 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets could not be decrypted and are not in this export.", + "Continue without the secrets that could not be decrypted": "Continue without the secrets that could not be decrypted", + "This request is no longer available.": "This request is no longer available.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.", + "{grantee}, waiting period in days: {days}": "{grantee}, waiting period in days: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.", + "Invalidated": "Invalidated", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Your key rotation removed this contact's emergency access. Designate them again if you still want them." }, "plurals": "", "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/es.js b/l10n/es.js index a350f7420..ceb54d2bb 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n secreto no se pudo descifrar y no está en esta exportación.", + "%n secrets could not be decrypted and are not in this export.": "%n secretos no se pudieron descifrar y no están en esta exportación.", + "Continue without the secrets that could not be decrypted": "Continuar sin los secretos que no se pudieron descifrar", + "This request is no longer available.": "Esta solicitud ya no está disponible.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Elige qué contactos de emergencia pueden recibir tu nueva clave. Marca solo a personas que hayas designado tú y en las que sigas confiando: quien tuvo tu sesión puede haber añadido un contacto propio. Los contactos que no marques pierden el acceso de emergencia; puedes volver a designarlos después.", + "{grantee}, waiting period in days: {days}": "{grantee}, periodo de espera en días: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "No confirmaste estos contactos, así que se eliminó su acceso de emergencia. Vuelve a designarlos solo si estás seguro de que los añadiste tú.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Estos contactos tenían una solicitud de acceso de emergencia pendiente o aprobada, así que no recibieron tu nueva clave. Así se vería un contacto añadido por otra persona: no vuelvas a designarlos a menos que sepas que la solicitud era legítima.", + "Invalidated": "Invalidado", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Este contacto tenía una solicitud de acceso de emergencia pendiente o aprobada cuando cambiaste tu clave, así que no recibió tu nueva clave. Así se vería un contacto añadido por otra persona: no vuelvas a designarlo a menos que sepas que la solicitud era legítima.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Tu rotación de clave se reanudó, así que estos contactos de emergencia no se pudieron trasladar y se les retiró el acceso de emergencia. Vuelve a añadirlos desde Acceso de emergencia si aún los quieres.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Tu rotación de clave retiró %n contacto de emergencia. Revisa Acceso de emergencia y vuelve a añadirlo si aún lo quieres.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Tu rotación de clave retiró %n contactos de emergencia. Revisa Acceso de emergencia y vuelve a añadirlos si aún los quieres.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Tu rotación de clave retiró el acceso de emergencia de este contacto. Vuelve a designarlo si aún lo quieres." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index d33006268..a89a7df7a 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n secreto no se pudo descifrar y no está en esta exportación.", + "%n secrets could not be decrypted and are not in this export.": "%n secretos no se pudieron descifrar y no están en esta exportación.", + "Continue without the secrets that could not be decrypted": "Continuar sin los secretos que no se pudieron descifrar", + "This request is no longer available.": "Esta solicitud ya no está disponible.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Elige qué contactos de emergencia pueden recibir tu nueva clave. Marca solo a personas que hayas designado tú y en las que sigas confiando: quien tuvo tu sesión puede haber añadido un contacto propio. Los contactos que no marques pierden el acceso de emergencia; puedes volver a designarlos después.", + "{grantee}, waiting period in days: {days}": "{grantee}, periodo de espera en días: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "No confirmaste estos contactos, así que se eliminó su acceso de emergencia. Vuelve a designarlos solo si estás seguro de que los añadiste tú.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Estos contactos tenían una solicitud de acceso de emergencia pendiente o aprobada, así que no recibieron tu nueva clave. Así se vería un contacto añadido por otra persona: no vuelvas a designarlos a menos que sepas que la solicitud era legítima.", + "Invalidated": "Invalidado", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Este contacto tenía una solicitud de acceso de emergencia pendiente o aprobada cuando cambiaste tu clave, así que no recibió tu nueva clave. Así se vería un contacto añadido por otra persona: no vuelvas a designarlo a menos que sepas que la solicitud era legítima.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Tu rotación de clave se reanudó, así que estos contactos de emergencia no se pudieron trasladar y se les retiró el acceso de emergencia. Vuelve a añadirlos desde Acceso de emergencia si aún los quieres.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Tu rotación de clave retiró %n contacto de emergencia. Revisa Acceso de emergencia y vuelve a añadirlo si aún lo quieres.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Tu rotación de clave retiró %n contactos de emergencia. Revisa Acceso de emergencia y vuelve a añadirlos si aún los quieres.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Tu rotación de clave retiró el acceso de emergencia de este contacto. Vuelve a designarlo si aún lo quieres." }, "plurals": null } diff --git a/l10n/et.js b/l10n/et.js index fc38a533e..c78b1de8c 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n saladust ei õnnestunud dekrüpteerida ja see ei ole selles ekspordis.", + "%n secrets could not be decrypted and are not in this export.": "%n saladust ei õnnestunud dekrüpteerida ja need ei ole selles ekspordis.", + "Continue without the secrets that could not be decrypted": "Jätka ilma saladusteta, mida ei õnnestunud dekrüpteerida", + "This request is no longer available.": "See taotlus pole enam saadaval.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Vali, millised hädaolukorra kontaktid võivad sinu uue võtme saada. Märgi ainult inimesed, kelle määrasid ise ja keda endiselt usaldad: see, kelle käes oli sinu seanss, võis lisada oma kontakti. Märkimata kontaktid kaotavad hädaolukorra juurdepääsu; saad need hiljem uuesti määrata.", + "{grantee}, waiting period in days: {days}": "{grantee}, ooteaeg päevades: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Sa ei kinnitanud neid kontakte, seega eemaldati nende hädaolukorra juurdepääs. Määra need uuesti ainult siis, kui oled kindel, et lisasid need ise.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Neil kontaktidel oli ootel või kinnitatud hädaolukorra juurdepääsu taotlus, seega nad ei saanud sinu uut võtit. Nii näeks välja kontakt, kelle lisas keegi teine: ära määra neid uuesti, kui sa ei tea, et taotlus oli ehtne.", + "Invalidated": "Kehtetuks muudetud", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Sellel kontaktil oli võtme vahetamise ajal ootel või kinnitatud hädaolukorra juurdepääsu taotlus, seega ta ei saanud sinu uut võtit. Nii näeks välja kontakt, kelle lisas keegi teine: ära määra teda uuesti, kui sa ei tea, et taotlus oli ehtne.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Sinu võtme rotatsiooni jätkati, seega neid hädaolukorra kontakte ei saanud üle kanda ja nende hädaolukorra ligipääs eemaldati. Lisa nad uuesti jaotises Hädaolukorra ligipääs, kui soovid neid endiselt.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa see uuesti, kui soovid seda endiselt.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa need uuesti, kui soovid neid endiselt.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Sinu võtme rotatsioon eemaldas selle kontakti hädaolukorra ligipääsu. Määra ta uuesti, kui soovid teda endiselt." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 9c36d4ae1..afece1dfd 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n saladust ei õnnestunud dekrüpteerida ja see ei ole selles ekspordis.", + "%n secrets could not be decrypted and are not in this export.": "%n saladust ei õnnestunud dekrüpteerida ja need ei ole selles ekspordis.", + "Continue without the secrets that could not be decrypted": "Jätka ilma saladusteta, mida ei õnnestunud dekrüpteerida", + "This request is no longer available.": "See taotlus pole enam saadaval.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Vali, millised hädaolukorra kontaktid võivad sinu uue võtme saada. Märgi ainult inimesed, kelle määrasid ise ja keda endiselt usaldad: see, kelle käes oli sinu seanss, võis lisada oma kontakti. Märkimata kontaktid kaotavad hädaolukorra juurdepääsu; saad need hiljem uuesti määrata.", + "{grantee}, waiting period in days: {days}": "{grantee}, ooteaeg päevades: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Sa ei kinnitanud neid kontakte, seega eemaldati nende hädaolukorra juurdepääs. Määra need uuesti ainult siis, kui oled kindel, et lisasid need ise.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Neil kontaktidel oli ootel või kinnitatud hädaolukorra juurdepääsu taotlus, seega nad ei saanud sinu uut võtit. Nii näeks välja kontakt, kelle lisas keegi teine: ära määra neid uuesti, kui sa ei tea, et taotlus oli ehtne.", + "Invalidated": "Kehtetuks muudetud", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Sellel kontaktil oli võtme vahetamise ajal ootel või kinnitatud hädaolukorra juurdepääsu taotlus, seega ta ei saanud sinu uut võtit. Nii näeks välja kontakt, kelle lisas keegi teine: ära määra teda uuesti, kui sa ei tea, et taotlus oli ehtne.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Sinu võtme rotatsiooni jätkati, seega neid hädaolukorra kontakte ei saanud üle kanda ja nende hädaolukorra ligipääs eemaldati. Lisa nad uuesti jaotises Hädaolukorra ligipääs, kui soovid neid endiselt.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa see uuesti, kui soovid seda endiselt.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa need uuesti, kui soovid neid endiselt.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Sinu võtme rotatsioon eemaldas selle kontakti hädaolukorra ligipääsu. Määra ta uuesti, kui soovid teda endiselt." }, "plurals": null } diff --git a/l10n/fi.js b/l10n/fi.js index 4b25bf03b..a222cec2a 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n salaisuutta ei voitu purkaa, eikä se ole tässä viennissä.", + "%n secrets could not be decrypted and are not in this export.": "%n salaisuutta ei voitu purkaa, eivätkä ne ole tässä viennissä.", + "Continue without the secrets that could not be decrypted": "Jatka ilman salaisuuksia, joita ei voitu purkaa", + "This request is no longer available.": "Tämä pyyntö ei ole enää käytettävissä.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Valitse, mitkä hätäyhteyshenkilöt saavat uuden avaimesi. Valitse vain henkilöitä, jotka olet itse nimennyt ja joihin luotat edelleen: istuntosi haltuunsa saanut on voinut lisätä oman yhteyshenkilön. Valitsematta jätetyt menettävät hätäkäyttöoikeutensa; voit nimetä heidät uudelleen myöhemmin.", + "{grantee}, waiting period in days: {days}": "{grantee}, odotusaika päivinä: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Et vahvistanut näitä yhteyshenkilöitä, joten heidän hätäkäyttöoikeutensa poistettiin. Nimeä heidät uudelleen vain, jos olet varma, että lisäsit heidät itse.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Näillä yhteyshenkilöillä oli odottava tai hyväksytty hätäkäyttöpyyntö, joten he eivät saaneet uutta avaintasi. Tältä näyttäisi jonkun toisen lisäämä yhteyshenkilö: älä nimeä heitä uudelleen, ellet tiedä pyynnön olleen aito.", + "Invalidated": "Mitätöity", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Tällä yhteyshenkilöllä oli odottava tai hyväksytty hätäkäyttöpyyntö, kun vaihdoit avaimesi, joten hän ei saanut uutta avaintasi. Tältä näyttäisi jonkun toisen lisäämä yhteyshenkilö: älä nimeä häntä uudelleen, ellet tiedä pyynnön olleen aito.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Avaimen kiertoa jatkettiin, joten näitä hätäyhteyshenkilöitä ei voitu siirtää ja heidän hätäkäyttöoikeutensa poistettiin. Lisää heidät uudelleen Hätäkäyttöoikeus-osiosta, jos haluat heidät yhä.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Avaimen kierto poisti %n hätäyhteyshenkilön. Tarkista Hätäkäyttöoikeus ja lisää hänet uudelleen, jos haluat hänet yhä.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Avaimen kierto poisti %n hätäyhteyshenkilöä. Tarkista Hätäkäyttöoikeus ja lisää heidät uudelleen, jos haluat heidät yhä.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Avaimen kierto poisti tämän yhteyshenkilön hätäkäyttöoikeuden. Nimeä hänet uudelleen, jos haluat hänet yhä." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index bce62b4eb..0c8fab5ad 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n salaisuutta ei voitu purkaa, eikä se ole tässä viennissä.", + "%n secrets could not be decrypted and are not in this export.": "%n salaisuutta ei voitu purkaa, eivätkä ne ole tässä viennissä.", + "Continue without the secrets that could not be decrypted": "Jatka ilman salaisuuksia, joita ei voitu purkaa", + "This request is no longer available.": "Tämä pyyntö ei ole enää käytettävissä.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Valitse, mitkä hätäyhteyshenkilöt saavat uuden avaimesi. Valitse vain henkilöitä, jotka olet itse nimennyt ja joihin luotat edelleen: istuntosi haltuunsa saanut on voinut lisätä oman yhteyshenkilön. Valitsematta jätetyt menettävät hätäkäyttöoikeutensa; voit nimetä heidät uudelleen myöhemmin.", + "{grantee}, waiting period in days: {days}": "{grantee}, odotusaika päivinä: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Et vahvistanut näitä yhteyshenkilöitä, joten heidän hätäkäyttöoikeutensa poistettiin. Nimeä heidät uudelleen vain, jos olet varma, että lisäsit heidät itse.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Näillä yhteyshenkilöillä oli odottava tai hyväksytty hätäkäyttöpyyntö, joten he eivät saaneet uutta avaintasi. Tältä näyttäisi jonkun toisen lisäämä yhteyshenkilö: älä nimeä heitä uudelleen, ellet tiedä pyynnön olleen aito.", + "Invalidated": "Mitätöity", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Tällä yhteyshenkilöllä oli odottava tai hyväksytty hätäkäyttöpyyntö, kun vaihdoit avaimesi, joten hän ei saanut uutta avaintasi. Tältä näyttäisi jonkun toisen lisäämä yhteyshenkilö: älä nimeä häntä uudelleen, ellet tiedä pyynnön olleen aito.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Avaimen kiertoa jatkettiin, joten näitä hätäyhteyshenkilöitä ei voitu siirtää ja heidän hätäkäyttöoikeutensa poistettiin. Lisää heidät uudelleen Hätäkäyttöoikeus-osiosta, jos haluat heidät yhä.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Avaimen kierto poisti %n hätäyhteyshenkilön. Tarkista Hätäkäyttöoikeus ja lisää hänet uudelleen, jos haluat hänet yhä.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Avaimen kierto poisti %n hätäyhteyshenkilöä. Tarkista Hätäkäyttöoikeus ja lisää heidät uudelleen, jos haluat heidät yhä.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Avaimen kierto poisti tämän yhteyshenkilön hätäkäyttöoikeuden. Nimeä hänet uudelleen, jos haluat hänet yhä." }, "plurals": null } diff --git a/l10n/fr.js b/l10n/fr.js index 7b7e9fb6f..f998d891e 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n secret n'a pas pu être déchiffré et ne figure pas dans cet export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets n'ont pas pu être déchiffrés et ne figurent pas dans cet export.", + "Continue without the secrets that could not be decrypted": "Continuer sans les secrets qui n'ont pas pu être déchiffrés", + "This request is no longer available.": "Cette demande n'est plus disponible.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Choisissez les contacts d'urgence qui peuvent recevoir votre nouvelle clé. Ne cochez que les personnes que vous avez vous-même désignées et en qui vous avez toujours confiance : la personne qui détenait votre session a pu ajouter son propre contact. Les contacts non cochés perdent leur accès d'urgence ; vous pourrez les désigner à nouveau ensuite.", + "{grantee}, waiting period in days: {days}": "{grantee}, délai d’attente en jours : {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Vous n'avez pas confirmé ces contacts, leur accès d'urgence a donc été supprimé. Ne les désignez à nouveau que si vous êtes sûr de les avoir ajoutés vous-même.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ces contacts avaient une demande d'accès d'urgence en attente ou approuvée ; ils n'ont donc pas reçu votre nouvelle clé. C'est ainsi que se présenterait un contact ajouté par quelqu'un d'autre : ne les désignez pas à nouveau, sauf si vous savez que la demande était légitime.", + "Invalidated": "Invalidé", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ce contact avait une demande d'accès d'urgence en attente ou approuvée lorsque vous avez changé votre clé ; il n'a donc pas reçu votre nouvelle clé. C'est ainsi que se présenterait un contact ajouté par quelqu'un d'autre : ne le désignez pas à nouveau, sauf si vous savez que la demande était légitime.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Votre rotation de clé a été reprise ; ces contacts d'urgence n'ont donc pas pu être transférés et leur accès d'urgence a été supprimé. Ajoutez-les à nouveau depuis Accès d'urgence si vous les souhaitez toujours.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Votre rotation de clé a supprimé %n contact d'urgence. Vérifiez Accès d'urgence et ajoutez-le à nouveau si vous le souhaitez toujours.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Votre rotation de clé a supprimé %n contacts d'urgence. Vérifiez Accès d'urgence et ajoutez-les à nouveau si vous les souhaitez toujours.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Votre rotation de clé a supprimé l'accès d'urgence de ce contact. Désignez-le à nouveau si vous le souhaitez toujours." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 6a43c16b3..3eb24aab2 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n secret n'a pas pu être déchiffré et ne figure pas dans cet export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets n'ont pas pu être déchiffrés et ne figurent pas dans cet export.", + "Continue without the secrets that could not be decrypted": "Continuer sans les secrets qui n'ont pas pu être déchiffrés", + "This request is no longer available.": "Cette demande n'est plus disponible.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Choisissez les contacts d'urgence qui peuvent recevoir votre nouvelle clé. Ne cochez que les personnes que vous avez vous-même désignées et en qui vous avez toujours confiance : la personne qui détenait votre session a pu ajouter son propre contact. Les contacts non cochés perdent leur accès d'urgence ; vous pourrez les désigner à nouveau ensuite.", + "{grantee}, waiting period in days: {days}": "{grantee}, délai d’attente en jours : {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Vous n'avez pas confirmé ces contacts, leur accès d'urgence a donc été supprimé. Ne les désignez à nouveau que si vous êtes sûr de les avoir ajoutés vous-même.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ces contacts avaient une demande d'accès d'urgence en attente ou approuvée ; ils n'ont donc pas reçu votre nouvelle clé. C'est ainsi que se présenterait un contact ajouté par quelqu'un d'autre : ne les désignez pas à nouveau, sauf si vous savez que la demande était légitime.", + "Invalidated": "Invalidé", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ce contact avait une demande d'accès d'urgence en attente ou approuvée lorsque vous avez changé votre clé ; il n'a donc pas reçu votre nouvelle clé. C'est ainsi que se présenterait un contact ajouté par quelqu'un d'autre : ne le désignez pas à nouveau, sauf si vous savez que la demande était légitime.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Votre rotation de clé a été reprise ; ces contacts d'urgence n'ont donc pas pu être transférés et leur accès d'urgence a été supprimé. Ajoutez-les à nouveau depuis Accès d'urgence si vous les souhaitez toujours.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Votre rotation de clé a supprimé %n contact d'urgence. Vérifiez Accès d'urgence et ajoutez-le à nouveau si vous le souhaitez toujours.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Votre rotation de clé a supprimé %n contacts d'urgence. Vérifiez Accès d'urgence et ajoutez-les à nouveau si vous les souhaitez toujours.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Votre rotation de clé a supprimé l'accès d'urgence de ce contact. Désignez-le à nouveau si vous le souhaitez toujours." }, "plurals": null } diff --git a/l10n/ga.js b/l10n/ga.js index 26daf7f1d..ee9c8dfa3 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets could not be decrypted and are not in this export.", + "Continue without the secrets that could not be decrypted": "Continue without the secrets that could not be decrypted", + "This request is no longer available.": "Níl an t-iarratas seo ar fáil a thuilleadh.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Roghnaigh cé na teagmhálaithe éigeandála a fhéadfaidh d’eochair nua a fháil. Ná cuir tic ach le daoine a d’ainmnigh tú féin agus a bhfuil muinín agat astu fós: d’fhéadfadh an té a raibh do sheisiún aige teagmhálaí dá chuid féin a chur leis. Cailleann teagmhálaithe gan tic a rochtain éigeandála; is féidir leat iad a ainmniú arís ina dhiaidh sin.", + "{grantee}, waiting period in days: {days}": "{grantee}, tréimhse feithimh i laethanta: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Níor dheimhnigh tú na teagmhálaithe seo, mar sin baineadh a rochtain éigeandála. Ná hainmnigh arís iad ach amháin má tá tú cinnte gur chuir tú féin leis iad.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Bhí iarratas ar rochtain éigeandála ar feitheamh nó ceadaithe ag na teagmhálaithe seo, mar sin ní bhfuair siad d’eochair nua. Sin mar a bheadh teagmhálaí a chuir duine eile leis: ná hainmnigh arís iad mura bhfuil a fhios agat go raibh an t-iarratas fíor.", + "Invalidated": "Neamhbhailithe", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Bhí iarratas ar rochtain éigeandála ar feitheamh nó ceadaithe ag an teagmhálaí seo nuair a d’athraigh tú d’eochair, mar sin ní bhfuair sé d’eochair nua. Sin mar a bheadh teagmhálaí a chuir duine eile leis: ná hainmnigh arís é mura bhfuil a fhios agat go raibh an t-iarratas fíor.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Atosaíodh do rothlú eochrach, mar sin níorbh fhéidir na teagmhálaithe éigeandála seo a thabhairt anonn agus baineadh a rochtain éigeandála. Cuir leis arís iad ó Rochtain éigeandála más mian leat iad fós.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Bhain do rothlú eochrach %n teagmhálaí éigeandála. Seiceáil Rochtain éigeandála agus cuir leis arís é más mian leat é fós.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Bhain do rothlú eochrach %n teagmhálaí éigeandála. Seiceáil Rochtain éigeandála agus cuir leis arís iad más mian leat iad fós.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Bhain do rothlú eochrach rochtain éigeandála an teagmhálaí seo. Ainmnigh arís é más mian leat é fós." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ga.json b/l10n/ga.json index 6c306d37f..9ac575399 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets could not be decrypted and are not in this export.", + "Continue without the secrets that could not be decrypted": "Continue without the secrets that could not be decrypted", + "This request is no longer available.": "Níl an t-iarratas seo ar fáil a thuilleadh.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Roghnaigh cé na teagmhálaithe éigeandála a fhéadfaidh d’eochair nua a fháil. Ná cuir tic ach le daoine a d’ainmnigh tú féin agus a bhfuil muinín agat astu fós: d’fhéadfadh an té a raibh do sheisiún aige teagmhálaí dá chuid féin a chur leis. Cailleann teagmhálaithe gan tic a rochtain éigeandála; is féidir leat iad a ainmniú arís ina dhiaidh sin.", + "{grantee}, waiting period in days: {days}": "{grantee}, tréimhse feithimh i laethanta: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Níor dheimhnigh tú na teagmhálaithe seo, mar sin baineadh a rochtain éigeandála. Ná hainmnigh arís iad ach amháin má tá tú cinnte gur chuir tú féin leis iad.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Bhí iarratas ar rochtain éigeandála ar feitheamh nó ceadaithe ag na teagmhálaithe seo, mar sin ní bhfuair siad d’eochair nua. Sin mar a bheadh teagmhálaí a chuir duine eile leis: ná hainmnigh arís iad mura bhfuil a fhios agat go raibh an t-iarratas fíor.", + "Invalidated": "Neamhbhailithe", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Bhí iarratas ar rochtain éigeandála ar feitheamh nó ceadaithe ag an teagmhálaí seo nuair a d’athraigh tú d’eochair, mar sin ní bhfuair sé d’eochair nua. Sin mar a bheadh teagmhálaí a chuir duine eile leis: ná hainmnigh arís é mura bhfuil a fhios agat go raibh an t-iarratas fíor.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Atosaíodh do rothlú eochrach, mar sin níorbh fhéidir na teagmhálaithe éigeandála seo a thabhairt anonn agus baineadh a rochtain éigeandála. Cuir leis arís iad ó Rochtain éigeandála más mian leat iad fós.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Bhain do rothlú eochrach %n teagmhálaí éigeandála. Seiceáil Rochtain éigeandála agus cuir leis arís é más mian leat é fós.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Bhain do rothlú eochrach %n teagmhálaí éigeandála. Seiceáil Rochtain éigeandála agus cuir leis arís iad más mian leat iad fós.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Bhain do rothlú eochrach rochtain éigeandála an teagmhálaí seo. Ainmnigh arís é más mian leat é fós." }, "plurals": null } diff --git a/l10n/hr.js b/l10n/hr.js index 7757ddd55..3df6cc0c4 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n tajna nije mogla biti dešifrirana i nije u ovom izvozu.", + "%n secrets could not be decrypted and are not in this export.": "%n tajni nije moglo biti dešifrirano i nisu u ovom izvozu.", + "Continue without the secrets that could not be decrypted": "Nastavi bez tajni koje nije bilo moguće dešifrirati", + "This request is no longer available.": "Ovaj zahtjev više nije dostupan.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Odaberite koji kontakti za hitne slučajeve smiju primiti vaš novi ključ. Označite samo osobe koje ste sami odredili i kojima i dalje vjerujete: tko je imao vašu sesiju, mogao je dodati vlastiti kontakt. Neoznačeni kontakti gube pristup u hitnim slučajevima; poslije ih možete ponovno odrediti.", + "{grantee}, waiting period in days: {days}": "{grantee}, razdoblje čekanja u danima: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Niste potvrdili ove kontakte pa je njihov pristup u hitnim slučajevima uklonjen. Ponovno ih odredite samo ako ste sigurni da ste ih sami dodali.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ovi kontakti imali su zahtjev za pristup u hitnim slučajevima na čekanju ili odobren pa nisu dobili vaš novi ključ. Tako bi izgledao kontakt koji je dodao netko drugi: ne određujte ih ponovno osim ako znate da je zahtjev bio stvaran.", + "Invalidated": "Poništeno", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ovaj kontakt imao je zahtjev za pristup u hitnim slučajevima na čekanju ili odobren kada ste promijenili ključ pa nije dobio vaš novi ključ. Tako bi izgledao kontakt koji je dodao netko drugi: ne određujte ga ponovno osim ako znate da je zahtjev bio stvaran.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa je nastavljena pa ovi kontakti za pristup u nuždi nisu mogli biti preneseni i njihov pristup u nuždi je uklonjen. Dodajte ih ponovno u odjeljku Pristup u nuždi ako ih još želite.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa uklonila je %n kontakt za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ga ponovno ako ga još želite.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa uklonila je %n kontakta za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ih ponovno ako ih još želite.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa uklonila je pristup u nuždi ovog kontakta. Odredite ga ponovno ako ga još želite." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hr.json b/l10n/hr.json index f41ee0fe3..95ac3d5da 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n tajna nije mogla biti dešifrirana i nije u ovom izvozu.", + "%n secrets could not be decrypted and are not in this export.": "%n tajni nije moglo biti dešifrirano i nisu u ovom izvozu.", + "Continue without the secrets that could not be decrypted": "Nastavi bez tajni koje nije bilo moguće dešifrirati", + "This request is no longer available.": "Ovaj zahtjev više nije dostupan.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Odaberite koji kontakti za hitne slučajeve smiju primiti vaš novi ključ. Označite samo osobe koje ste sami odredili i kojima i dalje vjerujete: tko je imao vašu sesiju, mogao je dodati vlastiti kontakt. Neoznačeni kontakti gube pristup u hitnim slučajevima; poslije ih možete ponovno odrediti.", + "{grantee}, waiting period in days: {days}": "{grantee}, razdoblje čekanja u danima: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Niste potvrdili ove kontakte pa je njihov pristup u hitnim slučajevima uklonjen. Ponovno ih odredite samo ako ste sigurni da ste ih sami dodali.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ovi kontakti imali su zahtjev za pristup u hitnim slučajevima na čekanju ili odobren pa nisu dobili vaš novi ključ. Tako bi izgledao kontakt koji je dodao netko drugi: ne određujte ih ponovno osim ako znate da je zahtjev bio stvaran.", + "Invalidated": "Poništeno", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ovaj kontakt imao je zahtjev za pristup u hitnim slučajevima na čekanju ili odobren kada ste promijenili ključ pa nije dobio vaš novi ključ. Tako bi izgledao kontakt koji je dodao netko drugi: ne određujte ga ponovno osim ako znate da je zahtjev bio stvaran.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa je nastavljena pa ovi kontakti za pristup u nuždi nisu mogli biti preneseni i njihov pristup u nuždi je uklonjen. Dodajte ih ponovno u odjeljku Pristup u nuždi ako ih još želite.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa uklonila je %n kontakt za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ga ponovno ako ga još želite.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa uklonila je %n kontakta za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ih ponovno ako ih još želite.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa uklonila je pristup u nuždi ovog kontakta. Odredite ga ponovno ako ga još želite." }, "plurals": null } diff --git a/l10n/hu.js b/l10n/hu.js index 380fba5a3..24b1f8d3a 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n titkot nem sikerült visszafejteni, és nincs benne ebben az exportban.", + "%n secrets could not be decrypted and are not in this export.": "%n titkot nem sikerült visszafejteni, és nincsenek benne ebben az exportban.", + "Continue without the secrets that could not be decrypted": "Folytatás a vissza nem fejthető titkok nélkül", + "This request is no longer available.": "Ez a kérelem már nem érhető el.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Válassza ki, mely vészhelyzeti kapcsolattartók kaphatják meg az új kulcsát. Csak olyan személyeket jelöljön be, akiket Ön jelölt ki, és akikben továbbra is megbízik: aki a munkamenetét birtokolta, hozzáadhatott saját kapcsolattartót. A be nem jelölt kapcsolattartók elveszítik a vészhelyzeti hozzáférést; később újra kijelölheti őket.", + "{grantee}, waiting period in days: {days}": "{grantee}, várakozási idő napokban: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Nem erősítette meg ezeket a kapcsolattartókat, ezért vészhelyzeti hozzáférésüket eltávolítottuk. Csak akkor jelölje ki őket újra, ha biztos benne, hogy Ön adta hozzá őket.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ezeknek a kapcsolattartóknak függőben lévő vagy jóváhagyott vészhelyzeti hozzáférési kérelmük volt, ezért nem kapták meg az új kulcsát. Így nézne ki egy mások által hozzáadott kapcsolattartó: ne jelölje ki őket újra, hacsak nem tudja, hogy a kérelem valódi volt.", + "Invalidated": "Érvénytelenítve", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ennek a kapcsolattartónak függőben lévő vagy jóváhagyott vészhelyzeti hozzáférési kérelme volt, amikor lecserélte a kulcsát, ezért nem kapta meg az új kulcsát. Így nézne ki egy mások által hozzáadott kapcsolattartó: ne jelölje ki újra, hacsak nem tudja, hogy a kérelem valódi volt.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "A kulcsrotáció folytatódott, ezért ezeket a vészhelyzeti kapcsolattartókat nem lehetett átvinni, és vészhelyzeti hozzáférésüket eltávolítottuk. Ha továbbra is szeretné őket, adja hozzá újra őket a Vészhelyzeti hozzáférés oldalon.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "A kulcsrotáció eltávolított %n vészhelyzeti kapcsolattartót. Nézze meg a Vészhelyzeti hozzáférést, és adja hozzá újra, ha továbbra is szeretné.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "A kulcsrotáció eltávolított %n vészhelyzeti kapcsolattartót. Nézze meg a Vészhelyzeti hozzáférést, és adja hozzá újra őket, ha továbbra is szeretné őket.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A kulcsrotáció eltávolította ennek a kapcsolattartónak a vészhelyzeti hozzáférését. Jelölje ki újra, ha továbbra is szeretné." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index 50a3bf6cc..d5bdabab7 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n titkot nem sikerült visszafejteni, és nincs benne ebben az exportban.", + "%n secrets could not be decrypted and are not in this export.": "%n titkot nem sikerült visszafejteni, és nincsenek benne ebben az exportban.", + "Continue without the secrets that could not be decrypted": "Folytatás a vissza nem fejthető titkok nélkül", + "This request is no longer available.": "Ez a kérelem már nem érhető el.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Válassza ki, mely vészhelyzeti kapcsolattartók kaphatják meg az új kulcsát. Csak olyan személyeket jelöljön be, akiket Ön jelölt ki, és akikben továbbra is megbízik: aki a munkamenetét birtokolta, hozzáadhatott saját kapcsolattartót. A be nem jelölt kapcsolattartók elveszítik a vészhelyzeti hozzáférést; később újra kijelölheti őket.", + "{grantee}, waiting period in days: {days}": "{grantee}, várakozási idő napokban: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Nem erősítette meg ezeket a kapcsolattartókat, ezért vészhelyzeti hozzáférésüket eltávolítottuk. Csak akkor jelölje ki őket újra, ha biztos benne, hogy Ön adta hozzá őket.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ezeknek a kapcsolattartóknak függőben lévő vagy jóváhagyott vészhelyzeti hozzáférési kérelmük volt, ezért nem kapták meg az új kulcsát. Így nézne ki egy mások által hozzáadott kapcsolattartó: ne jelölje ki őket újra, hacsak nem tudja, hogy a kérelem valódi volt.", + "Invalidated": "Érvénytelenítve", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ennek a kapcsolattartónak függőben lévő vagy jóváhagyott vészhelyzeti hozzáférési kérelme volt, amikor lecserélte a kulcsát, ezért nem kapta meg az új kulcsát. Így nézne ki egy mások által hozzáadott kapcsolattartó: ne jelölje ki újra, hacsak nem tudja, hogy a kérelem valódi volt.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "A kulcsrotáció folytatódott, ezért ezeket a vészhelyzeti kapcsolattartókat nem lehetett átvinni, és vészhelyzeti hozzáférésüket eltávolítottuk. Ha továbbra is szeretné őket, adja hozzá újra őket a Vészhelyzeti hozzáférés oldalon.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "A kulcsrotáció eltávolított %n vészhelyzeti kapcsolattartót. Nézze meg a Vészhelyzeti hozzáférést, és adja hozzá újra, ha továbbra is szeretné.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "A kulcsrotáció eltávolított %n vészhelyzeti kapcsolattartót. Nézze meg a Vészhelyzeti hozzáférést, és adja hozzá újra őket, ha továbbra is szeretné őket.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A kulcsrotáció eltávolította ennek a kapcsolattartónak a vészhelyzeti hozzáférését. Jelölje ki újra, ha továbbra is szeretné." }, "plurals": null } diff --git a/l10n/is.js b/l10n/is.js index c0a8ccf07..1f65887dd 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "Ekki tókst að afkóða %n leyndarmál og það er ekki í þessum útflutningi.", + "%n secrets could not be decrypted and are not in this export.": "Ekki tókst að afkóða %n leyndarmál og þau eru ekki í þessum útflutningi.", + "Continue without the secrets that could not be decrypted": "Halda áfram án leyndarmálanna sem ekki tókst að afkóða", + "This request is no longer available.": "Þessi beiðni er ekki lengur tiltæk.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Veldu hvaða neyðartengiliðir mega fá nýja lykilinn þinn. Hakaðu aðeins við fólk sem þú tilnefndir sjálf(ur) og treystir enn: sá sem hafði setuna þína gæti hafa bætt við eigin tengilið. Tengiliðir sem þú hakar ekki við missa neyðaraðgang sinn; þú getur tilnefnt þá aftur síðar.", + "{grantee}, waiting period in days: {days}": "{grantee}, biðtími í dögum: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Þú staðfestir ekki þessa tengiliði, svo neyðaraðgangur þeirra var fjarlægður. Tilnefndu þá aðeins aftur ef þú ert viss um að þú hafir bætt þeim við sjálf(ur).", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Þessir tengiliðir áttu biðandi eða samþykkta beiðni um neyðaraðgang, svo þeir fengu ekki nýja lykilinn þinn. Þannig myndi tengiliður sem einhver annar bætti við líta út: tilnefndu þá ekki aftur nema þú vitir að beiðnin hafi verið ósvikin.", + "Invalidated": "Ógilt", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Þessi tengiliður átti biðandi eða samþykkta beiðni um neyðaraðgang þegar þú skiptir um lykil, svo hann fékk ekki nýja lykilinn þinn. Þannig myndi tengiliður sem einhver annar bætti við líta út: tilnefndu hann ekki aftur nema þú vitir að beiðnin hafi verið ósvikin.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Lyklasnúningurinn var hafinn aftur, svo ekki var hægt að færa þessa neyðartengiliði yfir og neyðaraðgangur þeirra var fjarlægður. Bættu þeim aftur við í Neyðaraðgangi ef þú vilt þá enn.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Lyklasnúningurinn fjarlægði %n neyðartengilið. Skoðaðu Neyðaraðgang og bættu honum aftur við ef þú vilt hann enn.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Lyklasnúningurinn fjarlægði %n neyðartengiliði. Skoðaðu Neyðaraðgang og bættu þeim aftur við ef þú vilt þá enn.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Lyklasnúningurinn fjarlægði neyðaraðgang þessa tengiliðar. Tilnefndu hann aftur ef þú vilt hann enn." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 6ffacf9ef..3b585ac7d 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "Ekki tókst að afkóða %n leyndarmál og það er ekki í þessum útflutningi.", + "%n secrets could not be decrypted and are not in this export.": "Ekki tókst að afkóða %n leyndarmál og þau eru ekki í þessum útflutningi.", + "Continue without the secrets that could not be decrypted": "Halda áfram án leyndarmálanna sem ekki tókst að afkóða", + "This request is no longer available.": "Þessi beiðni er ekki lengur tiltæk.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Veldu hvaða neyðartengiliðir mega fá nýja lykilinn þinn. Hakaðu aðeins við fólk sem þú tilnefndir sjálf(ur) og treystir enn: sá sem hafði setuna þína gæti hafa bætt við eigin tengilið. Tengiliðir sem þú hakar ekki við missa neyðaraðgang sinn; þú getur tilnefnt þá aftur síðar.", + "{grantee}, waiting period in days: {days}": "{grantee}, biðtími í dögum: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Þú staðfestir ekki þessa tengiliði, svo neyðaraðgangur þeirra var fjarlægður. Tilnefndu þá aðeins aftur ef þú ert viss um að þú hafir bætt þeim við sjálf(ur).", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Þessir tengiliðir áttu biðandi eða samþykkta beiðni um neyðaraðgang, svo þeir fengu ekki nýja lykilinn þinn. Þannig myndi tengiliður sem einhver annar bætti við líta út: tilnefndu þá ekki aftur nema þú vitir að beiðnin hafi verið ósvikin.", + "Invalidated": "Ógilt", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Þessi tengiliður átti biðandi eða samþykkta beiðni um neyðaraðgang þegar þú skiptir um lykil, svo hann fékk ekki nýja lykilinn þinn. Þannig myndi tengiliður sem einhver annar bætti við líta út: tilnefndu hann ekki aftur nema þú vitir að beiðnin hafi verið ósvikin.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Lyklasnúningurinn var hafinn aftur, svo ekki var hægt að færa þessa neyðartengiliði yfir og neyðaraðgangur þeirra var fjarlægður. Bættu þeim aftur við í Neyðaraðgangi ef þú vilt þá enn.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Lyklasnúningurinn fjarlægði %n neyðartengilið. Skoðaðu Neyðaraðgang og bættu honum aftur við ef þú vilt hann enn.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Lyklasnúningurinn fjarlægði %n neyðartengiliði. Skoðaðu Neyðaraðgang og bættu þeim aftur við ef þú vilt þá enn.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Lyklasnúningurinn fjarlægði neyðaraðgang þessa tengiliðar. Tilnefndu hann aftur ef þú vilt hann enn." }, "plurals": null } diff --git a/l10n/it.js b/l10n/it.js index fa7d43784..0ec8d27a6 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n segreto non è stato decifrato e non è in questa esportazione.", + "%n secrets could not be decrypted and are not in this export.": "%n segreti non sono stati decifrati e non sono in questa esportazione.", + "Continue without the secrets that could not be decrypted": "Continua senza i segreti che non è stato possibile decifrare", + "This request is no longer available.": "Questa richiesta non è più disponibile.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Scegli quali contatti di emergenza possono ricevere la tua nuova chiave. Seleziona solo persone che hai designato tu e di cui ti fidi ancora: chi aveva la tua sessione potrebbe aver aggiunto un proprio contatto. I contatti non selezionati perdono l’accesso di emergenza; potrai designarli di nuovo in seguito.", + "{grantee}, waiting period in days: {days}": "{grantee}, periodo di attesa in giorni: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Non hai confermato questi contatti, quindi il loro accesso di emergenza è stato rimosso. Designali di nuovo solo se sei sicuro di averli aggiunti tu.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Questi contatti avevano una richiesta di accesso di emergenza in sospeso o approvata, quindi non hanno ricevuto la tua nuova chiave. È così che apparirebbe un contatto aggiunto da qualcun altro: non designarli di nuovo a meno che tu non sappia che la richiesta era autentica.", + "Invalidated": "Invalidato", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Questo contatto aveva una richiesta di accesso di emergenza in sospeso o approvata quando hai cambiato la chiave, quindi non ha ricevuto la tua nuova chiave. È così che apparirebbe un contatto aggiunto da qualcun altro: non designarlo di nuovo a meno che tu non sappia che la richiesta era autentica.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "La rotazione della chiave è stata ripresa, quindi questi contatti di emergenza non hanno potuto essere trasferiti e il loro accesso di emergenza è stato rimosso. Aggiungili di nuovo da Accesso di emergenza se li vuoi ancora.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "La rotazione della chiave ha rimosso %n contatto di emergenza. Controlla Accesso di emergenza e aggiungilo di nuovo se lo vuoi ancora.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "La rotazione della chiave ha rimosso %n contatti di emergenza. Controlla Accesso di emergenza e aggiungili di nuovo se li vuoi ancora.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotazione della chiave ha rimosso l'accesso di emergenza di questo contatto. Designalo di nuovo se lo vuoi ancora." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index dfec435f3..f83c34aee 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n segreto non è stato decifrato e non è in questa esportazione.", + "%n secrets could not be decrypted and are not in this export.": "%n segreti non sono stati decifrati e non sono in questa esportazione.", + "Continue without the secrets that could not be decrypted": "Continua senza i segreti che non è stato possibile decifrare", + "This request is no longer available.": "Questa richiesta non è più disponibile.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Scegli quali contatti di emergenza possono ricevere la tua nuova chiave. Seleziona solo persone che hai designato tu e di cui ti fidi ancora: chi aveva la tua sessione potrebbe aver aggiunto un proprio contatto. I contatti non selezionati perdono l’accesso di emergenza; potrai designarli di nuovo in seguito.", + "{grantee}, waiting period in days: {days}": "{grantee}, periodo di attesa in giorni: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Non hai confermato questi contatti, quindi il loro accesso di emergenza è stato rimosso. Designali di nuovo solo se sei sicuro di averli aggiunti tu.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Questi contatti avevano una richiesta di accesso di emergenza in sospeso o approvata, quindi non hanno ricevuto la tua nuova chiave. È così che apparirebbe un contatto aggiunto da qualcun altro: non designarli di nuovo a meno che tu non sappia che la richiesta era autentica.", + "Invalidated": "Invalidato", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Questo contatto aveva una richiesta di accesso di emergenza in sospeso o approvata quando hai cambiato la chiave, quindi non ha ricevuto la tua nuova chiave. È così che apparirebbe un contatto aggiunto da qualcun altro: non designarlo di nuovo a meno che tu non sappia che la richiesta era autentica.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "La rotazione della chiave è stata ripresa, quindi questi contatti di emergenza non hanno potuto essere trasferiti e il loro accesso di emergenza è stato rimosso. Aggiungili di nuovo da Accesso di emergenza se li vuoi ancora.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "La rotazione della chiave ha rimosso %n contatto di emergenza. Controlla Accesso di emergenza e aggiungilo di nuovo se lo vuoi ancora.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "La rotazione della chiave ha rimosso %n contatti di emergenza. Controlla Accesso di emergenza e aggiungili di nuovo se li vuoi ancora.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotazione della chiave ha rimosso l'accesso di emergenza di questo contatto. Designalo di nuovo se lo vuoi ancora." }, "plurals": null } diff --git a/l10n/lb.js b/l10n/lb.js index 4b41f88fa..14afc9239 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets could not be decrypted and are not in this export.", + "Continue without the secrets that could not be decrypted": "Continue without the secrets that could not be decrypted", + "This request is no longer available.": "Dës Ufro ass net méi disponibel.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Wielt, wéi eng Noutkontakter Äre neie Schlëssel kréien däerfen. Markéiert nëmme Leit, déi Dir selwer bestëmmt hutt an deenen Dir nach ëmmer vertraut: Wien Är Sessioun hat, kann e Kontakt vu sech bäigesat hunn. Net markéiert Kontakter verléieren hiren Noutzougang; Dir kënnt se duerno nees bestëmmen.", + "{grantee}, waiting period in days: {days}": "{grantee}, Waardezäit an Deeg: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Dir hutt dës Kontakter net confirméiert, dofir gouf hiren Noutzougang ewechgeholl. Bestëmmt se nëmmen nees, wann Dir sécher sidd, datt Dir se selwer bäigesat hutt.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Dës Kontakter haten eng oppen oder guttgeheescht Ufro fir Noutzougang, dofir hunn se Äre neie Schlëssel net kritt. Esou géif e Kontakt ausgesinn, deen een aneren bäigesat huet: bestëmmt se net nees, ausser Dir wësst, datt d’Ufro echt war.", + "Invalidated": "Ongëlteg", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Dëse Kontakt hat eng oppen oder guttgeheescht Ufro fir Noutzougang, wéi Dir Äre Schlëssel gewiesselt hutt, dofir huet en Äre neie Schlëssel net kritt. Esou géif e Kontakt ausgesinn, deen een aneren bäigesat huet: bestëmmt en net nees, ausser Dir wësst, datt d’Ufro echt war.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Är Schlësselrotatioun gouf weidergefouert, dofir konnten dës Noutfallkontakter net iwwerholl ginn an hiren Noutfallzougrëff gouf ewechgeholl. Setzt se nees bäi ënner Noutfallzougrëff, wann Dir se nach wëllt.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Är Schlësselrotatioun huet %n Noutfallkontakt ewechgeholl. Kuckt den Noutfallzougrëff a setzt en nees bäi, wann Dir en nach wëllt.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Är Schlësselrotatioun huet %n Noutfallkontakter ewechgeholl. Kuckt den Noutfallzougrëff a setzt se nees bäi, wann Dir se nach wëllt.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Är Schlësselrotatioun huet den Noutfallzougrëff vun dësem Kontakt ewechgeholl. Bestëmmt en nees, wann Dir en nach wëllt." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 6e2fb704b..2866ed47f 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets could not be decrypted and are not in this export.", + "Continue without the secrets that could not be decrypted": "Continue without the secrets that could not be decrypted", + "This request is no longer available.": "Dës Ufro ass net méi disponibel.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Wielt, wéi eng Noutkontakter Äre neie Schlëssel kréien däerfen. Markéiert nëmme Leit, déi Dir selwer bestëmmt hutt an deenen Dir nach ëmmer vertraut: Wien Är Sessioun hat, kann e Kontakt vu sech bäigesat hunn. Net markéiert Kontakter verléieren hiren Noutzougang; Dir kënnt se duerno nees bestëmmen.", + "{grantee}, waiting period in days: {days}": "{grantee}, Waardezäit an Deeg: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Dir hutt dës Kontakter net confirméiert, dofir gouf hiren Noutzougang ewechgeholl. Bestëmmt se nëmmen nees, wann Dir sécher sidd, datt Dir se selwer bäigesat hutt.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Dës Kontakter haten eng oppen oder guttgeheescht Ufro fir Noutzougang, dofir hunn se Äre neie Schlëssel net kritt. Esou géif e Kontakt ausgesinn, deen een aneren bäigesat huet: bestëmmt se net nees, ausser Dir wësst, datt d’Ufro echt war.", + "Invalidated": "Ongëlteg", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Dëse Kontakt hat eng oppen oder guttgeheescht Ufro fir Noutzougang, wéi Dir Äre Schlëssel gewiesselt hutt, dofir huet en Äre neie Schlëssel net kritt. Esou géif e Kontakt ausgesinn, deen een aneren bäigesat huet: bestëmmt en net nees, ausser Dir wësst, datt d’Ufro echt war.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Är Schlësselrotatioun gouf weidergefouert, dofir konnten dës Noutfallkontakter net iwwerholl ginn an hiren Noutfallzougrëff gouf ewechgeholl. Setzt se nees bäi ënner Noutfallzougrëff, wann Dir se nach wëllt.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Är Schlësselrotatioun huet %n Noutfallkontakt ewechgeholl. Kuckt den Noutfallzougrëff a setzt en nees bäi, wann Dir en nach wëllt.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Är Schlësselrotatioun huet %n Noutfallkontakter ewechgeholl. Kuckt den Noutfallzougrëff a setzt se nees bäi, wann Dir se nach wëllt.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Är Schlësselrotatioun huet den Noutfallzougrëff vun dësem Kontakt ewechgeholl. Bestëmmt en nees, wann Dir en nach wëllt." }, "plurals": null } diff --git a/l10n/lt.js b/l10n/lt.js index 68335e513..7ffe59c07 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n paslapties nepavyko iššifruoti, ir jos nėra šiame eksporte.", + "%n secrets could not be decrypted and are not in this export.": "%n paslapčių nepavyko iššifruoti, ir jų nėra šiame eksporte.", + "Continue without the secrets that could not be decrypted": "Tęsti be paslapčių, kurių nepavyko iššifruoti", + "This request is no longer available.": "Ši užklausa nebepasiekiama.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Pasirinkite, kurie skubios pagalbos kontaktai gali gauti jūsų naują raktą. Pažymėkite tik žmones, kuriuos paskyrėte patys ir kuriais vis dar pasitikite: tas, kas turėjo jūsų seansą, galėjo pridėti savo kontaktą. Nepažymėti kontaktai praranda skubią prieigą; vėliau galėsite juos paskirti iš naujo.", + "{grantee}, waiting period in days: {days}": "{grantee}, laukimo laikotarpis dienomis: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Nepatvirtinote šių kontaktų, todėl jų skubi prieiga buvo pašalinta. Paskirkite juos iš naujo tik jei esate tikri, kad pridėjote juos patys.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Šie kontaktai turėjo laukiančią arba patvirtintą skubios prieigos užklausą, todėl negavo jūsų naujo rakto. Taip atrodytų kontaktas, kurį pridėjo kažkas kitas: nepaskirkite jų iš naujo, nebent žinote, kad užklausa buvo tikra.", + "Invalidated": "Anuliuota", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Šis kontaktas turėjo laukiančią arba patvirtintą skubios prieigos užklausą, kai pakeitėte raktą, todėl negavo jūsų naujo rakto. Taip atrodytų kontaktas, kurį pridėjo kažkas kitas: nepaskirkite jo iš naujo, nebent žinote, kad užklausa buvo tikra.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rakto rotacija buvo pratęsta, todėl šių skubios prieigos kontaktų nepavyko perkelti ir jų prieiga nenumatytais atvejais pašalinta. Jei jų vis dar norite, vėl pridėkite juos skiltyje „Prieiga nenumatytais atvejais“.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rakto rotacija pašalino %n skubios prieigos kontaktą. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl jį pridėkite, jei jo vis dar norite.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rakto rotacija pašalino %n skubios prieigos kontaktus. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl juos pridėkite, jei jų vis dar norite.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rakto rotacija pašalino šio kontakto prieigą nenumatytais atvejais. Jei jo vis dar norite, paskirkite jį iš naujo." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.json b/l10n/lt.json index 5ab5558f6..ae7bd2804 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n paslapties nepavyko iššifruoti, ir jos nėra šiame eksporte.", + "%n secrets could not be decrypted and are not in this export.": "%n paslapčių nepavyko iššifruoti, ir jų nėra šiame eksporte.", + "Continue without the secrets that could not be decrypted": "Tęsti be paslapčių, kurių nepavyko iššifruoti", + "This request is no longer available.": "Ši užklausa nebepasiekiama.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Pasirinkite, kurie skubios pagalbos kontaktai gali gauti jūsų naują raktą. Pažymėkite tik žmones, kuriuos paskyrėte patys ir kuriais vis dar pasitikite: tas, kas turėjo jūsų seansą, galėjo pridėti savo kontaktą. Nepažymėti kontaktai praranda skubią prieigą; vėliau galėsite juos paskirti iš naujo.", + "{grantee}, waiting period in days: {days}": "{grantee}, laukimo laikotarpis dienomis: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Nepatvirtinote šių kontaktų, todėl jų skubi prieiga buvo pašalinta. Paskirkite juos iš naujo tik jei esate tikri, kad pridėjote juos patys.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Šie kontaktai turėjo laukiančią arba patvirtintą skubios prieigos užklausą, todėl negavo jūsų naujo rakto. Taip atrodytų kontaktas, kurį pridėjo kažkas kitas: nepaskirkite jų iš naujo, nebent žinote, kad užklausa buvo tikra.", + "Invalidated": "Anuliuota", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Šis kontaktas turėjo laukiančią arba patvirtintą skubios prieigos užklausą, kai pakeitėte raktą, todėl negavo jūsų naujo rakto. Taip atrodytų kontaktas, kurį pridėjo kažkas kitas: nepaskirkite jo iš naujo, nebent žinote, kad užklausa buvo tikra.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rakto rotacija buvo pratęsta, todėl šių skubios prieigos kontaktų nepavyko perkelti ir jų prieiga nenumatytais atvejais pašalinta. Jei jų vis dar norite, vėl pridėkite juos skiltyje „Prieiga nenumatytais atvejais“.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rakto rotacija pašalino %n skubios prieigos kontaktą. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl jį pridėkite, jei jo vis dar norite.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rakto rotacija pašalino %n skubios prieigos kontaktus. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl juos pridėkite, jei jų vis dar norite.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rakto rotacija pašalino šio kontakto prieigą nenumatytais atvejais. Jei jo vis dar norite, paskirkite jį iš naujo." }, "plurals": null } diff --git a/l10n/lv.js b/l10n/lv.js index 172b79e12..3227fb0ff 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n noslēpumu neizdevās atšifrēt, un tas nav šajā eksportā.", + "%n secrets could not be decrypted and are not in this export.": "%n noslēpumus neizdevās atšifrēt, un tie nav šajā eksportā.", + "Continue without the secrets that could not be decrypted": "Turpināt bez noslēpumiem, kurus neizdevās atšifrēt", + "This request is no longer available.": "Šis pieprasījums vairs nav pieejams.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Izvēlieties, kuras ārkārtas kontaktpersonas drīkst saņemt jūsu jauno atslēgu. Atzīmējiet tikai cilvēkus, kurus norīkojāt paši un kuriem joprojām uzticaties: tas, kuram bija jūsu sesija, varēja pievienot savu kontaktpersonu. Neatzīmētās kontaktpersonas zaudē ārkārtas piekļuvi; vēlāk varat tās norīkot atkārtoti.", + "{grantee}, waiting period in days: {days}": "{grantee}, gaidīšanas laiks dienās: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Jūs neapstiprinājāt šīs kontaktpersonas, tāpēc to ārkārtas piekļuve tika noņemta. Norīkojiet tās atkārtoti tikai tad, ja esat pārliecināts, ka pievienojāt tās pats.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Šīm kontaktpersonām bija gaidošs vai apstiprināts ārkārtas piekļuves pieprasījums, tāpēc tās nesaņēma jūsu jauno atslēgu. Tā izskatītos kontaktpersona, ko pievienojis kāds cits: nenorīkojiet tās atkārtoti, ja vien nezināt, ka pieprasījums bija īsts.", + "Invalidated": "Anulēts", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Šai kontaktpersonai bija gaidošs vai apstiprināts ārkārtas piekļuves pieprasījums, kad nomainījāt atslēgu, tāpēc tā nesaņēma jūsu jauno atslēgu. Tā izskatītos kontaktpersona, ko pievienojis kāds cits: nenorīkojiet to atkārtoti, ja vien nezināt, ka pieprasījums bija īsts.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Atslēgas rotācija tika atsākta, tāpēc šīs ārkārtas kontaktpersonas nevarēja pārnest un to ārkārtas piekļuve tika noņemta. Pievienojiet tās atkārtoti sadaļā Ārkārtas piekļuve, ja tās joprojām vēlaties.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Atslēgas rotācija noņēma %n ārkārtas kontaktpersonu. Pārbaudiet Ārkārtas piekļuvi un pievienojiet to atkārtoti, ja joprojām to vēlaties.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Atslēgas rotācija noņēma %n ārkārtas kontaktpersonas. Pārbaudiet Ārkārtas piekļuvi un pievienojiet tās atkārtoti, ja joprojām tās vēlaties.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Atslēgas rotācija noņēma šīs kontaktpersonas ārkārtas piekļuvi. Norīkojiet to atkārtoti, ja joprojām to vēlaties." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lv.json b/l10n/lv.json index 9081fb74e..4e3f3fa42 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n noslēpumu neizdevās atšifrēt, un tas nav šajā eksportā.", + "%n secrets could not be decrypted and are not in this export.": "%n noslēpumus neizdevās atšifrēt, un tie nav šajā eksportā.", + "Continue without the secrets that could not be decrypted": "Turpināt bez noslēpumiem, kurus neizdevās atšifrēt", + "This request is no longer available.": "Šis pieprasījums vairs nav pieejams.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Izvēlieties, kuras ārkārtas kontaktpersonas drīkst saņemt jūsu jauno atslēgu. Atzīmējiet tikai cilvēkus, kurus norīkojāt paši un kuriem joprojām uzticaties: tas, kuram bija jūsu sesija, varēja pievienot savu kontaktpersonu. Neatzīmētās kontaktpersonas zaudē ārkārtas piekļuvi; vēlāk varat tās norīkot atkārtoti.", + "{grantee}, waiting period in days: {days}": "{grantee}, gaidīšanas laiks dienās: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Jūs neapstiprinājāt šīs kontaktpersonas, tāpēc to ārkārtas piekļuve tika noņemta. Norīkojiet tās atkārtoti tikai tad, ja esat pārliecināts, ka pievienojāt tās pats.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Šīm kontaktpersonām bija gaidošs vai apstiprināts ārkārtas piekļuves pieprasījums, tāpēc tās nesaņēma jūsu jauno atslēgu. Tā izskatītos kontaktpersona, ko pievienojis kāds cits: nenorīkojiet tās atkārtoti, ja vien nezināt, ka pieprasījums bija īsts.", + "Invalidated": "Anulēts", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Šai kontaktpersonai bija gaidošs vai apstiprināts ārkārtas piekļuves pieprasījums, kad nomainījāt atslēgu, tāpēc tā nesaņēma jūsu jauno atslēgu. Tā izskatītos kontaktpersona, ko pievienojis kāds cits: nenorīkojiet to atkārtoti, ja vien nezināt, ka pieprasījums bija īsts.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Atslēgas rotācija tika atsākta, tāpēc šīs ārkārtas kontaktpersonas nevarēja pārnest un to ārkārtas piekļuve tika noņemta. Pievienojiet tās atkārtoti sadaļā Ārkārtas piekļuve, ja tās joprojām vēlaties.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Atslēgas rotācija noņēma %n ārkārtas kontaktpersonu. Pārbaudiet Ārkārtas piekļuvi un pievienojiet to atkārtoti, ja joprojām to vēlaties.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Atslēgas rotācija noņēma %n ārkārtas kontaktpersonas. Pārbaudiet Ārkārtas piekļuvi un pievienojiet tās atkārtoti, ja joprojām tās vēlaties.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Atslēgas rotācija noņēma šīs kontaktpersonas ārkārtas piekļuvi. Norīkojiet to atkārtoti, ja joprojām to vēlaties." }, "plurals": null } diff --git a/l10n/mk.js b/l10n/mk.js index 682e227ce..21574658b 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,43 @@ 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)": "Третирај ги тајните на комплетот како компромитирани (означи за ротација и извести ги сопствениците)", + "%n secret could not be decrypted and is not in this export.": "%n тајна не можеше да се дешифрира и не е во овој извоз.", + "%n secrets could not be decrypted and are not in this export.": "%n тајни не можеа да се дешифрираат и не се во овој извоз.", + "Continue without the secrets that could not be decrypted": "Продолжи без тајните што не можеа да се дешифрираат", + "This request is no longer available.": "Ова барање повеќе не е достапно.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Изберете кои контакти за итни случаи смеат да го добијат вашиот нов клуч. Означете само луѓе што ги одредивте сами и на кои сè уште им верувате: кој ја имал вашата сесија можеби додал свој контакт. Неозначените контакти го губат пристапот за итни случаи; подоцна можете повторно да ги одредите.", + "{grantee}, waiting period in days: {days}": "{grantee}, период на чекање во денови: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Не ги потврдивте овие контакти, па нивниот пристап за итни случаи е отстранет. Одредете ги повторно само ако сте сигурни дека ги додадовте сами.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Овие контакти имаа барање за пристап за итни случаи што чекаше или беше одобрено, па не го добија вашиот нов клуч. Вака би изгледал контакт што го додал некој друг: не одредувајте ги повторно освен ако знаете дека барањето било вистинско.", + "Invalidated": "Неважечко", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Овој контакт имаше барање за пристап за итни случаи што чекаше или беше одобрено кога го сменивте клучот, па не го доби вашиот нов клуч. Вака би изгледал контакт што го додал некој друг: не одредувајте го повторно освен ако знаете дека барањето било вистинско.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацијата на клучот беше продолжена, па овие контакти за итни случаи не можеа да се пренесат и нивниот пристап во итни случаи беше отстранет. Додајте ги повторно од „Пристап во итни случаи“ ако сè уште ги сакате.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротацијата на клучот отстрани %n контакт за итни случаи. Проверете „Пристап во итни случаи“ и додајте го повторно ако сè уште го сакате.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротацијата на клучот отстрани %n контакти за итни случаи. Проверете „Пристап во итни случаи“ и додајте ги повторно ако сè уште ги сакате.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацијата на клучот го отстрани пристапот во итни случаи на овој контакт. Одредете го повторно ако сè уште го сакате." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index dda98c4fa..23ac65470 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,43 @@ "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)": "Третирај ги тајните на комплетот како компромитирани (означи за ротација и извести ги сопствениците)", + "%n secret could not be decrypted and is not in this export.": "%n тајна не можеше да се дешифрира и не е во овој извоз.", + "%n secrets could not be decrypted and are not in this export.": "%n тајни не можеа да се дешифрираат и не се во овој извоз.", + "Continue without the secrets that could not be decrypted": "Продолжи без тајните што не можеа да се дешифрираат", + "This request is no longer available.": "Ова барање повеќе не е достапно.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Изберете кои контакти за итни случаи смеат да го добијат вашиот нов клуч. Означете само луѓе што ги одредивте сами и на кои сè уште им верувате: кој ја имал вашата сесија можеби додал свој контакт. Неозначените контакти го губат пристапот за итни случаи; подоцна можете повторно да ги одредите.", + "{grantee}, waiting period in days: {days}": "{grantee}, период на чекање во денови: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Не ги потврдивте овие контакти, па нивниот пристап за итни случаи е отстранет. Одредете ги повторно само ако сте сигурни дека ги додадовте сами.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Овие контакти имаа барање за пристап за итни случаи што чекаше или беше одобрено, па не го добија вашиот нов клуч. Вака би изгледал контакт што го додал некој друг: не одредувајте ги повторно освен ако знаете дека барањето било вистинско.", + "Invalidated": "Неважечко", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Овој контакт имаше барање за пристап за итни случаи што чекаше или беше одобрено кога го сменивте клучот, па не го доби вашиот нов клуч. Вака би изгледал контакт што го додал некој друг: не одредувајте го повторно освен ако знаете дека барањето било вистинско.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацијата на клучот беше продолжена, па овие контакти за итни случаи не можеа да се пренесат и нивниот пристап во итни случаи беше отстранет. Додајте ги повторно од „Пристап во итни случаи“ ако сè уште ги сакате.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротацијата на клучот отстрани %n контакт за итни случаи. Проверете „Пристап во итни случаи“ и додајте го повторно ако сè уште го сакате.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротацијата на клучот отстрани %n контакти за итни случаи. Проверете „Пристап во итни случаи“ и додајте ги повторно ако сè уште ги сакате.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацијата на клучот го отстрани пристапот во итни случаи на овој контакт. Одредете го повторно ако сè уште го сакате." }, "plurals": null } diff --git a/l10n/mt.js b/l10n/mt.js index ade1d4d5c..18400c936 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets could not be decrypted and are not in this export.", + "Continue without the secrets that could not be decrypted": "Continue without the secrets that could not be decrypted", + "This request is no longer available.": "Din it-talba m’għadhiex disponibbli.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Agħżel liema kuntatti ta’ emerġenza jistgħu jirċievu ċ-ċavetta l-ġdida tiegħek. Immarka biss nies li ħatart int stess u li għadek tafdahom: min kellu s-sessjoni tiegħek seta’ żied kuntatt tiegħu. Il-kuntatti li ma timmarkax jitilfu l-aċċess ta’ emerġenza; tista’ terġa’ taħtarhom wara.", + "{grantee}, waiting period in days: {days}": "{grantee}, perjodu ta’ stennija f’jiem: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Ma kkonfermajtx dawn il-kuntatti, għalhekk l-aċċess ta’ emerġenza tagħhom tneħħa. Erġa’ aħtarhom biss jekk inti ċert li żidthom int stess.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Dawn il-kuntatti kellhom talba għal aċċess ta’ emerġenza pendenti jew approvata, għalhekk ma rċevewx iċ-ċavetta l-ġdida tiegħek. Hekk jidher kuntatt miżjud minn xi ħadd ieħor: terġax taħtarhom sakemm ma tkunx taf li t-talba kienet ġenwina.", + "Invalidated": "Invalidat", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Dan il-kuntatt kellu talba għal aċċess ta’ emerġenza pendenti jew approvata meta biddilt iċ-ċavetta tiegħek, għalhekk ma rċeviex iċ-ċavetta l-ġdida tiegħek. Hekk jidher kuntatt miżjud minn xi ħadd ieħor: terġax taħtru sakemm ma tkunx taf li t-talba kienet ġenwina.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek tkompliet, għalhekk dawn il-kuntatti ta' emerġenza ma setgħux jiġu trasferiti u l-aċċess ta' emerġenza tagħhom tneħħa. Erġa' żidhom minn Aċċess ta' emerġenza jekk għadek tridhom.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet %n kuntatt ta' emerġenza. Iċċekkja Aċċess ta' emerġenza u erġa' żidu jekk għadek tridu.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet %n kuntatti ta' emerġenza. Iċċekkja Aċċess ta' emerġenza u erġa' żidhom jekk għadek tridhom.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet l-aċċess ta' emerġenza ta' dan il-kuntatt. Erġa' aħtru jekk għadek tridu." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 94970fc19..0eec20332 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets could not be decrypted and are not in this export.", + "Continue without the secrets that could not be decrypted": "Continue without the secrets that could not be decrypted", + "This request is no longer available.": "Din it-talba m’għadhiex disponibbli.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Agħżel liema kuntatti ta’ emerġenza jistgħu jirċievu ċ-ċavetta l-ġdida tiegħek. Immarka biss nies li ħatart int stess u li għadek tafdahom: min kellu s-sessjoni tiegħek seta’ żied kuntatt tiegħu. Il-kuntatti li ma timmarkax jitilfu l-aċċess ta’ emerġenza; tista’ terġa’ taħtarhom wara.", + "{grantee}, waiting period in days: {days}": "{grantee}, perjodu ta’ stennija f’jiem: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Ma kkonfermajtx dawn il-kuntatti, għalhekk l-aċċess ta’ emerġenza tagħhom tneħħa. Erġa’ aħtarhom biss jekk inti ċert li żidthom int stess.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Dawn il-kuntatti kellhom talba għal aċċess ta’ emerġenza pendenti jew approvata, għalhekk ma rċevewx iċ-ċavetta l-ġdida tiegħek. Hekk jidher kuntatt miżjud minn xi ħadd ieħor: terġax taħtarhom sakemm ma tkunx taf li t-talba kienet ġenwina.", + "Invalidated": "Invalidat", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Dan il-kuntatt kellu talba għal aċċess ta’ emerġenza pendenti jew approvata meta biddilt iċ-ċavetta tiegħek, għalhekk ma rċeviex iċ-ċavetta l-ġdida tiegħek. Hekk jidher kuntatt miżjud minn xi ħadd ieħor: terġax taħtru sakemm ma tkunx taf li t-talba kienet ġenwina.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek tkompliet, għalhekk dawn il-kuntatti ta' emerġenza ma setgħux jiġu trasferiti u l-aċċess ta' emerġenza tagħhom tneħħa. Erġa' żidhom minn Aċċess ta' emerġenza jekk għadek tridhom.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet %n kuntatt ta' emerġenza. Iċċekkja Aċċess ta' emerġenza u erġa' żidu jekk għadek tridu.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet %n kuntatti ta' emerġenza. Iċċekkja Aċċess ta' emerġenza u erġa' żidhom jekk għadek tridhom.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet l-aċċess ta' emerġenza ta' dan il-kuntatt. Erġa' aħtru jekk għadek tridu." }, "plurals": null } diff --git a/l10n/nb.js b/l10n/nb.js index 28f10740a..ee04f8ff6 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n hemmelighet kunne ikke dekrypteres og er ikke med i denne eksporten.", + "%n secrets could not be decrypted and are not in this export.": "%n hemmeligheter kunne ikke dekrypteres og er ikke med i denne eksporten.", + "Continue without the secrets that could not be decrypted": "Fortsett uten hemmelighetene som ikke kunne dekrypteres", + "This request is no longer available.": "Denne forespørselen er ikke lenger tilgjengelig.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Velg hvilke nødkontakter som kan motta den nye nøkkelen din. Kryss bare av for personer du selv har utpekt og fortsatt stoler på: den som hadde økten din, kan ha lagt til en egen kontakt. Kontakter du ikke krysser av, mister nødtilgangen; du kan utpeke dem på nytt etterpå.", + "{grantee}, waiting period in days: {days}": "{grantee}, ventetid i dager: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Du bekreftet ikke disse kontaktene, så nødtilgangen deres er fjernet. Utpek dem bare på nytt hvis du er sikker på at du selv la dem til.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Disse kontaktene hadde en ventende eller godkjent forespørsel om nødtilgang, så de fikk ikke den nye nøkkelen din. Slik ville en kontakt lagt til av noen andre sett ut: ikke utpek dem på nytt med mindre du vet at forespørselen var ekte.", + "Invalidated": "Ugyldiggjort", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Denne kontakten hadde en ventende eller godkjent forespørsel om nødtilgang da du byttet nøkkel, så den fikk ikke den nye nøkkelen din. Slik ville en kontakt lagt til av noen andre sett ut: ikke utpek den på nytt med mindre du vet at forespørselen var ekte.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Nøkkelrotasjonen ble gjenopptatt, så disse nødkontaktene kunne ikke overføres, og nødtilgangen deres ble fjernet. Legg dem til igjen under Nødtilgang hvis du fortsatt vil ha dem.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Nøkkelrotasjonen fjernet %n nødkontakt. Sjekk Nødtilgang og legg den til igjen hvis du fortsatt vil ha den.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Nøkkelrotasjonen fjernet %n nødkontakter. Sjekk Nødtilgang og legg dem til igjen hvis du fortsatt vil ha dem.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Nøkkelrotasjonen fjernet nødtilgangen til denne kontakten. Utpek den på nytt hvis du fortsatt vil ha den." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index aad1ac3d7..5d3a21206 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n hemmelighet kunne ikke dekrypteres og er ikke med i denne eksporten.", + "%n secrets could not be decrypted and are not in this export.": "%n hemmeligheter kunne ikke dekrypteres og er ikke med i denne eksporten.", + "Continue without the secrets that could not be decrypted": "Fortsett uten hemmelighetene som ikke kunne dekrypteres", + "This request is no longer available.": "Denne forespørselen er ikke lenger tilgjengelig.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Velg hvilke nødkontakter som kan motta den nye nøkkelen din. Kryss bare av for personer du selv har utpekt og fortsatt stoler på: den som hadde økten din, kan ha lagt til en egen kontakt. Kontakter du ikke krysser av, mister nødtilgangen; du kan utpeke dem på nytt etterpå.", + "{grantee}, waiting period in days: {days}": "{grantee}, ventetid i dager: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Du bekreftet ikke disse kontaktene, så nødtilgangen deres er fjernet. Utpek dem bare på nytt hvis du er sikker på at du selv la dem til.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Disse kontaktene hadde en ventende eller godkjent forespørsel om nødtilgang, så de fikk ikke den nye nøkkelen din. Slik ville en kontakt lagt til av noen andre sett ut: ikke utpek dem på nytt med mindre du vet at forespørselen var ekte.", + "Invalidated": "Ugyldiggjort", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Denne kontakten hadde en ventende eller godkjent forespørsel om nødtilgang da du byttet nøkkel, så den fikk ikke den nye nøkkelen din. Slik ville en kontakt lagt til av noen andre sett ut: ikke utpek den på nytt med mindre du vet at forespørselen var ekte.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Nøkkelrotasjonen ble gjenopptatt, så disse nødkontaktene kunne ikke overføres, og nødtilgangen deres ble fjernet. Legg dem til igjen under Nødtilgang hvis du fortsatt vil ha dem.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Nøkkelrotasjonen fjernet %n nødkontakt. Sjekk Nødtilgang og legg den til igjen hvis du fortsatt vil ha den.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Nøkkelrotasjonen fjernet %n nødkontakter. Sjekk Nødtilgang og legg dem til igjen hvis du fortsatt vil ha dem.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Nøkkelrotasjonen fjernet nødtilgangen til denne kontakten. Utpek den på nytt hvis du fortsatt vil ha den." }, "plurals": null } diff --git a/l10n/nl.js b/l10n/nl.js index 18fade367..21d00b618 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n geheim kon niet worden ontsleuteld en zit niet in deze export.", + "%n secrets could not be decrypted and are not in this export.": "%n geheimen konden niet worden ontsleuteld en zitten niet in deze export.", + "Continue without the secrets that could not be decrypted": "Doorgaan zonder de geheimen die niet konden worden ontsleuteld", + "This request is no longer available.": "Dit verzoek is niet langer beschikbaar.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Kies welke noodcontacten je nieuwe sleutel mogen ontvangen. Vink alleen mensen aan die je zelf hebt aangewezen en nog steeds vertrouwt: wie je sessie in handen had, kan zelf een contact hebben toegevoegd. Contacten die je niet aanvinkt, verliezen hun noodtoegang; je kunt ze daarna opnieuw aanwijzen.", + "{grantee}, waiting period in days: {days}": "{grantee}, wachttijd in dagen: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Je hebt deze contacten niet bevestigd, dus hun noodtoegang is verwijderd. Wijs ze alleen opnieuw aan als je zeker weet dat je ze zelf hebt toegevoegd.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Voor deze contacten stond een verzoek om noodtoegang open of was het al goedgekeurd, dus ze hebben je nieuwe sleutel niet gekregen. Zo ziet een contact eruit dat iemand anders heeft toegevoegd: wijs ze niet opnieuw aan tenzij je weet dat het verzoek echt was.", + "Invalidated": "Ongeldig gemaakt", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Voor dit contact stond een verzoek om noodtoegang open of was het al goedgekeurd toen je je sleutel wisselde, dus het heeft je nieuwe sleutel niet gekregen. Zo ziet een contact eruit dat iemand anders heeft toegevoegd: wijs het niet opnieuw aan tenzij je weet dat het verzoek echt was.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Je sleutelrotatie is hervat, dus deze noodcontacten konden niet worden meegenomen en hun noodtoegang is verwijderd. Voeg ze opnieuw toe via Noodtoegang als je ze nog wilt.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Je sleutelrotatie heeft %n noodcontact verwijderd. Kijk bij Noodtoegang en voeg het opnieuw toe als je het nog wilt.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Je sleutelrotatie heeft %n noodcontacten verwijderd. Kijk bij Noodtoegang en voeg ze opnieuw toe als je ze nog wilt.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Je sleutelrotatie heeft de noodtoegang van dit contact verwijderd. Wijs het opnieuw aan als je het nog wilt." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index bfcf541d5..88c6d4e99 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n geheim kon niet worden ontsleuteld en zit niet in deze export.", + "%n secrets could not be decrypted and are not in this export.": "%n geheimen konden niet worden ontsleuteld en zitten niet in deze export.", + "Continue without the secrets that could not be decrypted": "Doorgaan zonder de geheimen die niet konden worden ontsleuteld", + "This request is no longer available.": "Dit verzoek is niet langer beschikbaar.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Kies welke noodcontacten je nieuwe sleutel mogen ontvangen. Vink alleen mensen aan die je zelf hebt aangewezen en nog steeds vertrouwt: wie je sessie in handen had, kan zelf een contact hebben toegevoegd. Contacten die je niet aanvinkt, verliezen hun noodtoegang; je kunt ze daarna opnieuw aanwijzen.", + "{grantee}, waiting period in days: {days}": "{grantee}, wachttijd in dagen: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Je hebt deze contacten niet bevestigd, dus hun noodtoegang is verwijderd. Wijs ze alleen opnieuw aan als je zeker weet dat je ze zelf hebt toegevoegd.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Voor deze contacten stond een verzoek om noodtoegang open of was het al goedgekeurd, dus ze hebben je nieuwe sleutel niet gekregen. Zo ziet een contact eruit dat iemand anders heeft toegevoegd: wijs ze niet opnieuw aan tenzij je weet dat het verzoek echt was.", + "Invalidated": "Ongeldig gemaakt", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Voor dit contact stond een verzoek om noodtoegang open of was het al goedgekeurd toen je je sleutel wisselde, dus het heeft je nieuwe sleutel niet gekregen. Zo ziet een contact eruit dat iemand anders heeft toegevoegd: wijs het niet opnieuw aan tenzij je weet dat het verzoek echt was.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Je sleutelrotatie is hervat, dus deze noodcontacten konden niet worden meegenomen en hun noodtoegang is verwijderd. Voeg ze opnieuw toe via Noodtoegang als je ze nog wilt.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Je sleutelrotatie heeft %n noodcontact verwijderd. Kijk bij Noodtoegang en voeg het opnieuw toe als je het nog wilt.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Je sleutelrotatie heeft %n noodcontacten verwijderd. Kijk bij Noodtoegang en voeg ze opnieuw toe als je ze nog wilt.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Je sleutelrotatie heeft de noodtoegang van dit contact verwijderd. Wijs het opnieuw aan als je het nog wilt." }, "plurals": null, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/pl.js b/l10n/pl.js index f779ab675..e58acc3f2 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "Nie udało się odszyfrować %n sekretu i nie ma go w tym eksporcie.", + "%n secrets could not be decrypted and are not in this export.": "Nie udało się odszyfrować %n sekretów i nie ma ich w tym eksporcie.", + "Continue without the secrets that could not be decrypted": "Kontynuuj bez sekretów, których nie udało się odszyfrować", + "This request is no longer available.": "Ten wniosek nie jest już dostępny.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Wybierz, którzy kontakty awaryjne mogą otrzymać Twój nowy klucz. Zaznacz tylko osoby, które wyznaczyłeś sam i którym nadal ufasz: ktoś, kto miał Twoją sesję, mógł dodać własny kontakt. Niezaznaczone kontakty tracą dostęp awaryjny; możesz je później wyznaczyć ponownie.", + "{grantee}, waiting period in days: {days}": "{grantee}, okres oczekiwania w dniach: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Nie potwierdziłeś tych kontaktów, więc ich dostęp awaryjny został usunięty. Wyznacz je ponownie tylko wtedy, gdy masz pewność, że dodałeś je sam.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Te kontakty miały oczekujący lub zatwierdzony wniosek o dostęp awaryjny, więc nie otrzymały Twojego nowego klucza. Tak wyglądałby kontakt dodany przez kogoś innego: nie wyznaczaj ich ponownie, chyba że wiesz, że wniosek był prawdziwy.", + "Invalidated": "Unieważniony", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ten kontakt miał oczekujący lub zatwierdzony wniosek o dostęp awaryjny, gdy zmieniałeś klucz, więc nie otrzymał Twojego nowego klucza. Tak wyglądałby kontakt dodany przez kogoś innego: nie wyznaczaj go ponownie, chyba że wiesz, że wniosek był prawdziwy.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacja klucza została wznowiona, więc tych kontaktów awaryjnych nie dało się przenieść, a ich dostęp awaryjny został usunięty. Dodaj je ponownie w sekcji Dostęp awaryjny, jeśli nadal ich chcesz.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacja klucza usunęła %n kontakt awaryjny. Sprawdź Dostęp awaryjny i dodaj go ponownie, jeśli nadal go chcesz.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacja klucza usunęła %n kontaktów awaryjnych. Sprawdź Dostęp awaryjny i dodaj je ponownie, jeśli nadal ich chcesz.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacja klucza usunęła dostęp awaryjny tego kontaktu. Wyznacz go ponownie, jeśli nadal go chcesz." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.json b/l10n/pl.json index 1f845d527..b4b1c25e2 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "Nie udało się odszyfrować %n sekretu i nie ma go w tym eksporcie.", + "%n secrets could not be decrypted and are not in this export.": "Nie udało się odszyfrować %n sekretów i nie ma ich w tym eksporcie.", + "Continue without the secrets that could not be decrypted": "Kontynuuj bez sekretów, których nie udało się odszyfrować", + "This request is no longer available.": "Ten wniosek nie jest już dostępny.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Wybierz, którzy kontakty awaryjne mogą otrzymać Twój nowy klucz. Zaznacz tylko osoby, które wyznaczyłeś sam i którym nadal ufasz: ktoś, kto miał Twoją sesję, mógł dodać własny kontakt. Niezaznaczone kontakty tracą dostęp awaryjny; możesz je później wyznaczyć ponownie.", + "{grantee}, waiting period in days: {days}": "{grantee}, okres oczekiwania w dniach: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Nie potwierdziłeś tych kontaktów, więc ich dostęp awaryjny został usunięty. Wyznacz je ponownie tylko wtedy, gdy masz pewność, że dodałeś je sam.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Te kontakty miały oczekujący lub zatwierdzony wniosek o dostęp awaryjny, więc nie otrzymały Twojego nowego klucza. Tak wyglądałby kontakt dodany przez kogoś innego: nie wyznaczaj ich ponownie, chyba że wiesz, że wniosek był prawdziwy.", + "Invalidated": "Unieważniony", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ten kontakt miał oczekujący lub zatwierdzony wniosek o dostęp awaryjny, gdy zmieniałeś klucz, więc nie otrzymał Twojego nowego klucza. Tak wyglądałby kontakt dodany przez kogoś innego: nie wyznaczaj go ponownie, chyba że wiesz, że wniosek był prawdziwy.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacja klucza została wznowiona, więc tych kontaktów awaryjnych nie dało się przenieść, a ich dostęp awaryjny został usunięty. Dodaj je ponownie w sekcji Dostęp awaryjny, jeśli nadal ich chcesz.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacja klucza usunęła %n kontakt awaryjny. Sprawdź Dostęp awaryjny i dodaj go ponownie, jeśli nadal go chcesz.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacja klucza usunęła %n kontaktów awaryjnych. Sprawdź Dostęp awaryjny i dodaj je ponownie, jeśli nadal ich chcesz.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacja klucza usunęła dostęp awaryjny tego kontaktu. Wyznacz go ponownie, jeśli nadal go chcesz." }, "plurals": null } diff --git a/l10n/pt.js b/l10n/pt.js index 7a46c6e21..f0d8cf023 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n segredo não pôde ser desencriptado e não está nesta exportação.", + "%n secrets could not be decrypted and are not in this export.": "%n segredos não puderam ser desencriptados e não estão nesta exportação.", + "Continue without the secrets that could not be decrypted": "Continuar sem os segredos que não puderam ser desencriptados", + "This request is no longer available.": "Este pedido já não está disponível.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Escolha que contactos de emergência podem receber a sua nova chave. Assinale apenas pessoas que designou e em quem continua a confiar: quem teve a sua sessão pode ter adicionado um contacto próprio. Os contactos que não assinalar perdem o acesso de emergência; pode designá-los novamente depois.", + "{grantee}, waiting period in days: {days}": "{grantee}, período de espera em dias: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Não confirmou estes contactos, pelo que o acesso de emergência foi removido. Designe-os novamente apenas se tiver a certeza de que foi você que os adicionou.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Estes contactos tinham um pedido de acesso de emergência pendente ou aprovado, pelo que não receberam a sua nova chave. É assim que se apresentaria um contacto adicionado por outra pessoa: não os designe novamente, a menos que saiba que o pedido era genuíno.", + "Invalidated": "Invalidado", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Este contacto tinha um pedido de acesso de emergência pendente ou aprovado quando mudou a sua chave, pelo que não recebeu a sua nova chave. É assim que se apresentaria um contacto adicionado por outra pessoa: não o designe novamente, a menos que saiba que o pedido era genuíno.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "A rotação da chave foi retomada, pelo que estes contactos de emergência não puderam ser transferidos e o seu acesso de emergência foi removido. Adicione-os novamente em Acesso de emergência se ainda os quiser.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "A rotação da chave removeu %n contacto de emergência. Verifique Acesso de emergência e adicione-o novamente se ainda o quiser.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "A rotação da chave removeu %n contactos de emergência. Verifique Acesso de emergência e adicione-os novamente se ainda os quiser.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A rotação da chave removeu o acesso de emergência deste contacto. Designe-o novamente se ainda o quiser." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 96c733df6..62b8983d7 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n segredo não pôde ser desencriptado e não está nesta exportação.", + "%n secrets could not be decrypted and are not in this export.": "%n segredos não puderam ser desencriptados e não estão nesta exportação.", + "Continue without the secrets that could not be decrypted": "Continuar sem os segredos que não puderam ser desencriptados", + "This request is no longer available.": "Este pedido já não está disponível.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Escolha que contactos de emergência podem receber a sua nova chave. Assinale apenas pessoas que designou e em quem continua a confiar: quem teve a sua sessão pode ter adicionado um contacto próprio. Os contactos que não assinalar perdem o acesso de emergência; pode designá-los novamente depois.", + "{grantee}, waiting period in days: {days}": "{grantee}, período de espera em dias: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Não confirmou estes contactos, pelo que o acesso de emergência foi removido. Designe-os novamente apenas se tiver a certeza de que foi você que os adicionou.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Estes contactos tinham um pedido de acesso de emergência pendente ou aprovado, pelo que não receberam a sua nova chave. É assim que se apresentaria um contacto adicionado por outra pessoa: não os designe novamente, a menos que saiba que o pedido era genuíno.", + "Invalidated": "Invalidado", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Este contacto tinha um pedido de acesso de emergência pendente ou aprovado quando mudou a sua chave, pelo que não recebeu a sua nova chave. É assim que se apresentaria um contacto adicionado por outra pessoa: não o designe novamente, a menos que saiba que o pedido era genuíno.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "A rotação da chave foi retomada, pelo que estes contactos de emergência não puderam ser transferidos e o seu acesso de emergência foi removido. Adicione-os novamente em Acesso de emergência se ainda os quiser.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "A rotação da chave removeu %n contacto de emergência. Verifique Acesso de emergência e adicione-o novamente se ainda o quiser.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "A rotação da chave removeu %n contactos de emergência. Verifique Acesso de emergência e adicione-os novamente se ainda os quiser.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A rotação da chave removeu o acesso de emergência deste contacto. Designe-o novamente se ainda o quiser." }, "plurals": null } diff --git a/l10n/rm.js b/l10n/rm.js index 10aaff5c9..2bfdbd8a1 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets could not be decrypted and are not in this export.", + "Continue without the secrets that could not be decrypted": "Continue without the secrets that could not be decrypted", + "This request is no longer available.": "Questa dumonda n’è betg pli disponibla.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Tschernai tge contacts d’urgenza dastgan retschaiver Vossa nova clav. Marcai mo persunas che Vus avais designà sezs e che Vus fidais anc: tgi che aveva Vossa sessiun ha forsa agiuntà in agen contact. Contacts che Vus na marcais betg perdan lur access d’urgenza; Vus als pudais designar danovamain suenter.", + "{grantee}, waiting period in days: {days}": "{grantee}, temp d’spetga en dis: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Vus n’avais betg confermà quests contacts, perquai è lur access d’urgenza vegnì allontanà. Designai els danovamain mo sche Vus essas segir d’als avair agiuntà sezs.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Quests contacts avevan ina dumonda d’access d’urgenza pendenta u approvada, perquai n’han els betg retschavì Vossa nova clav. Uschia vesess or in contact agiuntà d’insatgi auter: na designai betg els danovamain, nun ch’è Vus savais che la dumonda era genuina.", + "Invalidated": "Invalidà", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Quest contact aveva ina dumonda d’access d’urgenza pendenta u approvada cura che Vus avais midà Vossa clav, perquai n’ha el betg retschavì Vossa nova clav. Uschia vesess or in contact agiuntà d’insatgi auter: na designai betg el danovamain, nun ch’è Vus savais che la dumonda era genuina.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Vossa rotaziun da la clav è vegnida cuntinuada, perquai n'hai quests contacts d'urgenza betg pudì vegnir transferids ed lur access d'urgenza è vegnì allontanà. Agiuntai els danovamain sut Access d'urgenza, sche Vus als vulais anc.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Vossa rotaziun da la clav ha allontanà %n contact d'urgenza. Controllai Access d'urgenza ed agiuntai el danovamain, sche Vus al vulais anc.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Vossa rotaziun da la clav ha allontanà %n contacts d'urgenza. Controllai Access d'urgenza ed agiuntai els danovamain, sche Vus als vulais anc.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Vossa rotaziun da la clav ha allontanà l'access d'urgenza da quest contact. Designai el danovamain, sche Vus al vulais anc." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 2abeb7f3e..1117edb2c 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrets could not be decrypted and are not in this export.", + "Continue without the secrets that could not be decrypted": "Continue without the secrets that could not be decrypted", + "This request is no longer available.": "Questa dumonda n’è betg pli disponibla.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Tschernai tge contacts d’urgenza dastgan retschaiver Vossa nova clav. Marcai mo persunas che Vus avais designà sezs e che Vus fidais anc: tgi che aveva Vossa sessiun ha forsa agiuntà in agen contact. Contacts che Vus na marcais betg perdan lur access d’urgenza; Vus als pudais designar danovamain suenter.", + "{grantee}, waiting period in days: {days}": "{grantee}, temp d’spetga en dis: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Vus n’avais betg confermà quests contacts, perquai è lur access d’urgenza vegnì allontanà. Designai els danovamain mo sche Vus essas segir d’als avair agiuntà sezs.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Quests contacts avevan ina dumonda d’access d’urgenza pendenta u approvada, perquai n’han els betg retschavì Vossa nova clav. Uschia vesess or in contact agiuntà d’insatgi auter: na designai betg els danovamain, nun ch’è Vus savais che la dumonda era genuina.", + "Invalidated": "Invalidà", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Quest contact aveva ina dumonda d’access d’urgenza pendenta u approvada cura che Vus avais midà Vossa clav, perquai n’ha el betg retschavì Vossa nova clav. Uschia vesess or in contact agiuntà d’insatgi auter: na designai betg el danovamain, nun ch’è Vus savais che la dumonda era genuina.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Vossa rotaziun da la clav è vegnida cuntinuada, perquai n'hai quests contacts d'urgenza betg pudì vegnir transferids ed lur access d'urgenza è vegnì allontanà. Agiuntai els danovamain sut Access d'urgenza, sche Vus als vulais anc.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Vossa rotaziun da la clav ha allontanà %n contact d'urgenza. Controllai Access d'urgenza ed agiuntai el danovamain, sche Vus al vulais anc.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Vossa rotaziun da la clav ha allontanà %n contacts d'urgenza. Controllai Access d'urgenza ed agiuntai els danovamain, sche Vus als vulais anc.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Vossa rotaziun da la clav ha allontanà l'access d'urgenza da quest contact. Designai el danovamain, sche Vus al vulais anc." }, "plurals": null } diff --git a/l10n/ro.js b/l10n/ro.js index 6af73d1a3..0a535d6b6 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n secret nu a putut fi decriptat și nu este în acest export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrete nu au putut fi decriptate și nu sunt în acest export.", + "Continue without the secrets that could not be decrypted": "Continuă fără secretele care nu au putut fi decriptate", + "This request is no longer available.": "Această cerere nu mai este disponibilă.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Alegeți ce contacte de urgență pot primi noua dvs. cheie. Bifați doar persoanele pe care le-ați desemnat dvs. și în care încă aveți încredere: cine v-a deținut sesiunea poate să fi adăugat un contact propriu. Contactele nebifate își pierd accesul de urgență; le puteți desemna din nou ulterior.", + "{grantee}, waiting period in days: {days}": "{grantee}, perioadă de așteptare în zile: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Nu ați confirmat aceste contacte, așa că accesul lor de urgență a fost eliminat. Desemnați-le din nou doar dacă sunteți sigur că le-ați adăugat dvs.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Aceste contacte aveau o cerere de acces de urgență în așteptare sau aprobată, așa că nu au primit noua dvs. cheie. Așa ar arăta un contact adăugat de altcineva: nu le desemnați din nou decât dacă știți că cererea a fost autentică.", + "Invalidated": "Invalidat", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Acest contact avea o cerere de acces de urgență în așteptare sau aprobată când v-ați schimbat cheia, așa că nu a primit noua dvs. cheie. Așa ar arăta un contact adăugat de altcineva: nu îl desemnați din nou decât dacă știți că cererea a fost autentică.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotația cheii a fost reluată, așa că aceste contacte de urgență nu au putut fi transferate, iar accesul lor de urgență a fost eliminat. Adăugați-le din nou din Acces de urgență dacă le mai doriți.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotația cheii a eliminat %n contact de urgență. Verificați Acces de urgență și adăugați-l din nou dacă îl mai doriți.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotația cheii a eliminat %n contacte de urgență. Verificați Acces de urgență și adăugați-le din nou dacă le mai doriți.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotația cheii a eliminat accesul de urgență al acestui contact. Desemnați-l din nou dacă îl mai doriți." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.json b/l10n/ro.json index 0c8df7bfb..d874ffa9a 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n secret nu a putut fi decriptat și nu este în acest export.", + "%n secrets could not be decrypted and are not in this export.": "%n secrete nu au putut fi decriptate și nu sunt în acest export.", + "Continue without the secrets that could not be decrypted": "Continuă fără secretele care nu au putut fi decriptate", + "This request is no longer available.": "Această cerere nu mai este disponibilă.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Alegeți ce contacte de urgență pot primi noua dvs. cheie. Bifați doar persoanele pe care le-ați desemnat dvs. și în care încă aveți încredere: cine v-a deținut sesiunea poate să fi adăugat un contact propriu. Contactele nebifate își pierd accesul de urgență; le puteți desemna din nou ulterior.", + "{grantee}, waiting period in days: {days}": "{grantee}, perioadă de așteptare în zile: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Nu ați confirmat aceste contacte, așa că accesul lor de urgență a fost eliminat. Desemnați-le din nou doar dacă sunteți sigur că le-ați adăugat dvs.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Aceste contacte aveau o cerere de acces de urgență în așteptare sau aprobată, așa că nu au primit noua dvs. cheie. Așa ar arăta un contact adăugat de altcineva: nu le desemnați din nou decât dacă știți că cererea a fost autentică.", + "Invalidated": "Invalidat", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Acest contact avea o cerere de acces de urgență în așteptare sau aprobată când v-ați schimbat cheia, așa că nu a primit noua dvs. cheie. Așa ar arăta un contact adăugat de altcineva: nu îl desemnați din nou decât dacă știți că cererea a fost autentică.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotația cheii a fost reluată, așa că aceste contacte de urgență nu au putut fi transferate, iar accesul lor de urgență a fost eliminat. Adăugați-le din nou din Acces de urgență dacă le mai doriți.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotația cheii a eliminat %n contact de urgență. Verificați Acces de urgență și adăugați-l din nou dacă îl mai doriți.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotația cheii a eliminat %n contacte de urgență. Verificați Acces de urgență și adăugați-le din nou dacă le mai doriți.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotația cheii a eliminat accesul de urgență al acestui contact. Desemnați-l din nou dacă îl mai doriți." }, "plurals": null } diff --git a/l10n/ru.js b/l10n/ru.js index f84f753e2..51346f251 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,43 @@ 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)": "Считать секреты набора скомпрометированными (отметить для ротации и уведомить владельцев)", + "%n secret could not be decrypted and is not in this export.": "%n секрет не удалось расшифровать, и его нет в этом экспорте.", + "%n secrets could not be decrypted and are not in this export.": "%n секретов не удалось расшифровать, и их нет в этом экспорте.", + "Continue without the secrets that could not be decrypted": "Продолжить без секретов, которые не удалось расшифровать", + "This request is no longer available.": "Этот запрос больше не доступен.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Выберите, какие экстренные контакты могут получить ваш новый ключ. Отмечайте только людей, которых вы назначили сами и которым по-прежнему доверяете: тот, у кого была ваша сессия, мог добавить собственный контакт. Неотмеченные контакты теряют экстренный доступ; позже вы можете назначить их снова.", + "{grantee}, waiting period in days: {days}": "{grantee}, период ожидания в днях: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Вы не подтвердили эти контакты, поэтому их экстренный доступ удалён. Назначайте их снова, только если уверены, что добавили их сами.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "У этих контактов был ожидающий или одобренный запрос на экстренный доступ, поэтому они не получили ваш новый ключ. Именно так выглядел бы контакт, добавленный кем-то другим: не назначайте их снова, если не знаете, что запрос был настоящим.", + "Invalidated": "Аннулировано", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "У этого контакта был ожидающий или одобренный запрос на экстренный доступ, когда вы сменили ключ, поэтому он не получил ваш новый ключ. Именно так выглядел бы контакт, добавленный кем-то другим: не назначайте его снова, если не знаете, что запрос был настоящим.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротация ключа была возобновлена, поэтому эти экстренные контакты не удалось перенести и их экстренный доступ удалён. Добавьте их снова в разделе «Экстренный доступ», если они вам ещё нужны.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротация ключа удалила %n экстренный контакт. Проверьте «Экстренный доступ» и добавьте его снова, если он вам ещё нужен.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротация ключа удалила %n экстренных контактов. Проверьте «Экстренный доступ» и добавьте их снова, если они вам ещё нужны.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротация ключа удалила экстренный доступ этого контакта. Назначьте его снова, если он вам ещё нужен." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ru.json b/l10n/ru.json index 9fe5f6204..b62a19aa0 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,43 @@ "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)": "Считать секреты набора скомпрометированными (отметить для ротации и уведомить владельцев)", + "%n secret could not be decrypted and is not in this export.": "%n секрет не удалось расшифровать, и его нет в этом экспорте.", + "%n secrets could not be decrypted and are not in this export.": "%n секретов не удалось расшифровать, и их нет в этом экспорте.", + "Continue without the secrets that could not be decrypted": "Продолжить без секретов, которые не удалось расшифровать", + "This request is no longer available.": "Этот запрос больше не доступен.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Выберите, какие экстренные контакты могут получить ваш новый ключ. Отмечайте только людей, которых вы назначили сами и которым по-прежнему доверяете: тот, у кого была ваша сессия, мог добавить собственный контакт. Неотмеченные контакты теряют экстренный доступ; позже вы можете назначить их снова.", + "{grantee}, waiting period in days: {days}": "{grantee}, период ожидания в днях: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Вы не подтвердили эти контакты, поэтому их экстренный доступ удалён. Назначайте их снова, только если уверены, что добавили их сами.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "У этих контактов был ожидающий или одобренный запрос на экстренный доступ, поэтому они не получили ваш новый ключ. Именно так выглядел бы контакт, добавленный кем-то другим: не назначайте их снова, если не знаете, что запрос был настоящим.", + "Invalidated": "Аннулировано", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "У этого контакта был ожидающий или одобренный запрос на экстренный доступ, когда вы сменили ключ, поэтому он не получил ваш новый ключ. Именно так выглядел бы контакт, добавленный кем-то другим: не назначайте его снова, если не знаете, что запрос был настоящим.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротация ключа была возобновлена, поэтому эти экстренные контакты не удалось перенести и их экстренный доступ удалён. Добавьте их снова в разделе «Экстренный доступ», если они вам ещё нужны.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротация ключа удалила %n экстренный контакт. Проверьте «Экстренный доступ» и добавьте его снова, если он вам ещё нужен.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротация ключа удалила %n экстренных контактов. Проверьте «Экстренный доступ» и добавьте их снова, если они вам ещё нужны.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротация ключа удалила экстренный доступ этого контакта. Назначьте его снова, если он вам ещё нужен." }, "plurals": null } diff --git a/l10n/sk.js b/l10n/sk.js index 9fd4e54fe..ce063adec 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n tajomstvo sa nepodarilo dešifrovať a nie je v tomto exporte.", + "%n secrets could not be decrypted and are not in this export.": "%n tajomstiev sa nepodarilo dešifrovať a nie sú v tomto exporte.", + "Continue without the secrets that could not be decrypted": "Pokračovať bez tajomstiev, ktoré sa nepodarilo dešifrovať", + "This request is no longer available.": "Táto žiadosť už nie je k dispozícii.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Vyberte, ktoré núdzové kontakty môžu dostať váš nový kľúč. Označte len osoby, ktoré ste určili sami a ktorým stále dôverujete: ten, kto mal vašu reláciu, mohol pridať vlastný kontakt. Neoznačené kontakty stratia núdzový prístup; neskôr ich môžete určiť znova.", + "{grantee}, waiting period in days: {days}": "{grantee}, čakacia doba v dňoch: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Tieto kontakty ste nepotvrdili, a preto bol ich núdzový prístup odstránený. Určte ich znova len vtedy, ak ste si istí, že ste ich pridali sami.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Tieto kontakty mali čakajúcu alebo schválenú žiadosť o núdzový prístup, a preto nedostali váš nový kľúč. Takto by vyzeral kontakt, ktorý pridal niekto iný: neurčujte ich znova, pokiaľ neviete, že žiadosť bola skutočná.", + "Invalidated": "Zneplatnené", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Tento kontakt mal pri zmene vášho kľúča čakajúcu alebo schválenú žiadosť o núdzový prístup, a preto nedostal váš nový kľúč. Takto by vyzeral kontakt, ktorý pridal niekto iný: neurčujte ho znova, pokiaľ neviete, že žiadosť bola skutočná.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotácia kľúča bola obnovená, a preto tieto núdzové kontakty nebolo možné preniesť a ich prístup pre naliehavé prípady bol odstránený. Ak ich stále chcete, pridajte ich znova v časti Prístup pre naliehavé prípady.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotácia kľúča odstránila %n núdzový kontakt. Skontrolujte Prístup pre naliehavé prípady a pridajte ho znova, ak ho stále chcete.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotácia kľúča odstránila %n núdzových kontaktov. Skontrolujte Prístup pre naliehavé prípady a pridajte ich znova, ak ich stále chcete.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotácia kľúča odstránila tomuto kontaktu prístup pre naliehavé prípady. Ak ho stále chcete, určte ho znova." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sk.json b/l10n/sk.json index 2da4905f9..0bd362488 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n tajomstvo sa nepodarilo dešifrovať a nie je v tomto exporte.", + "%n secrets could not be decrypted and are not in this export.": "%n tajomstiev sa nepodarilo dešifrovať a nie sú v tomto exporte.", + "Continue without the secrets that could not be decrypted": "Pokračovať bez tajomstiev, ktoré sa nepodarilo dešifrovať", + "This request is no longer available.": "Táto žiadosť už nie je k dispozícii.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Vyberte, ktoré núdzové kontakty môžu dostať váš nový kľúč. Označte len osoby, ktoré ste určili sami a ktorým stále dôverujete: ten, kto mal vašu reláciu, mohol pridať vlastný kontakt. Neoznačené kontakty stratia núdzový prístup; neskôr ich môžete určiť znova.", + "{grantee}, waiting period in days: {days}": "{grantee}, čakacia doba v dňoch: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Tieto kontakty ste nepotvrdili, a preto bol ich núdzový prístup odstránený. Určte ich znova len vtedy, ak ste si istí, že ste ich pridali sami.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Tieto kontakty mali čakajúcu alebo schválenú žiadosť o núdzový prístup, a preto nedostali váš nový kľúč. Takto by vyzeral kontakt, ktorý pridal niekto iný: neurčujte ich znova, pokiaľ neviete, že žiadosť bola skutočná.", + "Invalidated": "Zneplatnené", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Tento kontakt mal pri zmene vášho kľúča čakajúcu alebo schválenú žiadosť o núdzový prístup, a preto nedostal váš nový kľúč. Takto by vyzeral kontakt, ktorý pridal niekto iný: neurčujte ho znova, pokiaľ neviete, že žiadosť bola skutočná.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotácia kľúča bola obnovená, a preto tieto núdzové kontakty nebolo možné preniesť a ich prístup pre naliehavé prípady bol odstránený. Ak ich stále chcete, pridajte ich znova v časti Prístup pre naliehavé prípady.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotácia kľúča odstránila %n núdzový kontakt. Skontrolujte Prístup pre naliehavé prípady a pridajte ho znova, ak ho stále chcete.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotácia kľúča odstránila %n núdzových kontaktov. Skontrolujte Prístup pre naliehavé prípady a pridajte ich znova, ak ich stále chcete.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotácia kľúča odstránila tomuto kontaktu prístup pre naliehavé prípady. Ak ho stále chcete, určte ho znova." }, "plurals": null } diff --git a/l10n/sl.js b/l10n/sl.js index ba47c3d1f..bbabf22bf 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n skrivnosti ni bilo mogoče dešifrirati in je ni v tem izvozu.", + "%n secrets could not be decrypted and are not in this export.": "%n skrivnosti ni bilo mogoče dešifrirati in jih ni v tem izvozu.", + "Continue without the secrets that could not be decrypted": "Nadaljuj brez skrivnosti, ki jih ni bilo mogoče dešifrirati", + "This request is no longer available.": "Ta zahteva ni več na voljo.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Izberite, kateri stiki za nujne primere lahko prejmejo vaš novi ključ. Označite samo osebe, ki ste jih določili sami in jim še vedno zaupate: kdor je imel vašo sejo, je morda dodal svoj stik. Neoznačeni stiki izgubijo dostop v nujnih primerih; pozneje jih lahko znova določite.", + "{grantee}, waiting period in days: {days}": "{grantee}, čakalna doba v dneh: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Teh stikov niste potrdili, zato je bil njihov dostop v nujnih primerih odstranjen. Znova jih določite le, če ste prepričani, da ste jih dodali sami.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ti stiki so imeli zahtevo za dostop v nujnih primerih, ki je čakala ali je bila odobrena, zato niso prejeli vašega novega ključa. Tako bi bil videti stik, ki ga je dodal nekdo drug: ne določite jih znova, razen če veste, da je bila zahteva pristna.", + "Invalidated": "Razveljavljeno", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ta stik je imel ob zamenjavi vašega ključa zahtevo za dostop v nujnih primerih, ki je čakala ali je bila odobrena, zato ni prejel vašega novega ključa. Tako bi bil videti stik, ki ga je dodal nekdo drug: ne določite ga znova, razen če veste, da je bila zahteva pristna.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa se je nadaljevala, zato teh stikov za nujne primere ni bilo mogoče prenesti in njihov dostop v nujnih primerih je bil odstranjen. Če jih še želite, jih znova dodajte v razdelku Dostop v nujnih primerih.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa je odstranila %n stik za nujne primere. Preverite Dostop v nujnih primerih in ga znova dodajte, če ga še želite.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa je odstranila %n stikov za nujne primere. Preverite Dostop v nujnih primerih in jih znova dodajte, če jih še želite.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je odstranila dostop v nujnih primerih za ta stik. Če ga še želite, ga znova določite." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sl.json b/l10n/sl.json index 657a0ed99..0db1d30a6 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n skrivnosti ni bilo mogoče dešifrirati in je ni v tem izvozu.", + "%n secrets could not be decrypted and are not in this export.": "%n skrivnosti ni bilo mogoče dešifrirati in jih ni v tem izvozu.", + "Continue without the secrets that could not be decrypted": "Nadaljuj brez skrivnosti, ki jih ni bilo mogoče dešifrirati", + "This request is no longer available.": "Ta zahteva ni več na voljo.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Izberite, kateri stiki za nujne primere lahko prejmejo vaš novi ključ. Označite samo osebe, ki ste jih določili sami in jim še vedno zaupate: kdor je imel vašo sejo, je morda dodal svoj stik. Neoznačeni stiki izgubijo dostop v nujnih primerih; pozneje jih lahko znova določite.", + "{grantee}, waiting period in days: {days}": "{grantee}, čakalna doba v dneh: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Teh stikov niste potrdili, zato je bil njihov dostop v nujnih primerih odstranjen. Znova jih določite le, če ste prepričani, da ste jih dodali sami.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ti stiki so imeli zahtevo za dostop v nujnih primerih, ki je čakala ali je bila odobrena, zato niso prejeli vašega novega ključa. Tako bi bil videti stik, ki ga je dodal nekdo drug: ne določite jih znova, razen če veste, da je bila zahteva pristna.", + "Invalidated": "Razveljavljeno", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ta stik je imel ob zamenjavi vašega ključa zahtevo za dostop v nujnih primerih, ki je čakala ali je bila odobrena, zato ni prejel vašega novega ključa. Tako bi bil videti stik, ki ga je dodal nekdo drug: ne določite ga znova, razen če veste, da je bila zahteva pristna.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa se je nadaljevala, zato teh stikov za nujne primere ni bilo mogoče prenesti in njihov dostop v nujnih primerih je bil odstranjen. Če jih še želite, jih znova dodajte v razdelku Dostop v nujnih primerih.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa je odstranila %n stik za nujne primere. Preverite Dostop v nujnih primerih in ga znova dodajte, če ga še želite.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa je odstranila %n stikov za nujne primere. Preverite Dostop v nujnih primerih in jih znova dodajte, če jih še želite.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je odstranila dostop v nujnih primerih za ta stik. Če ga še želite, ga znova določite." }, "plurals": null } diff --git a/l10n/sq.js b/l10n/sq.js index 46c90de01..d6942d8c4 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n sekret nuk mund të deshifrohej dhe nuk është në këtë eksport.", + "%n secrets could not be decrypted and are not in this export.": "%n sekrete nuk mund të deshifroheshin dhe nuk janë në këtë eksport.", + "Continue without the secrets that could not be decrypted": "Vazhdo pa sekretet që nuk mund të deshifroheshin", + "This request is no longer available.": "Kjo kërkesë nuk është më e disponueshme.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Zgjidhni cilët kontakte urgjence mund të marrin çelësin tuaj të ri. Shënoni vetëm personat që i keni caktuar vetë dhe tek të cilët ende keni besim: kushdo që ka pasur seancën tuaj mund të ketë shtuar një kontakt të vetin. Kontaktet që nuk i shënoni humbasin qasjen e urgjencës; mund t’i caktoni sërish më pas.", + "{grantee}, waiting period in days: {days}": "{grantee}, periudha e pritjes në ditë: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Nuk i konfirmuat këta kontakte, prandaj qasja e tyre e urgjencës u hoq. Caktojini sërish vetëm nëse jeni i sigurt se i keni shtuar vetë.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Këta kontakte kishin një kërkesë për qasje urgjence në pritje ose të miratuar, prandaj nuk morën çelësin tuaj të ri. Kështu do të dukej një kontakt i shtuar nga dikush tjetër: mos i caktoni sërish, përveç nëse e dini se kërkesa ishte e vërtetë.", + "Invalidated": "E pavlefshme", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ky kontakt kishte një kërkesë për qasje urgjence në pritje ose të miratuar kur ndërruat çelësin, prandaj nuk mori çelësin tuaj të ri. Kështu do të dukej një kontakt i shtuar nga dikush tjetër: mos e caktoni sërish, përveç nëse e dini se kërkesa ishte e vërtetë.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rrotullimi i kyçit u rifillua, prandaj këta kontakte emergjence nuk mund të barteshin dhe aksesi i tyre i emergjencës u hoq. Shtojini përsëri nga Aksesi i emergjencës nëse i doni ende.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rrotullimi i kyçit hoqi %n kontakt emergjence. Kontrolloni Aksesin e emergjencës dhe shtojeni përsëri nëse e doni ende.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rrotullimi i kyçit hoqi %n kontakte emergjence. Kontrolloni Aksesin e emergjencës dhe shtojini përsëri nëse i doni ende.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rrotullimi i kyçit hoqi aksesin e emergjencës së këtij kontakti. Caktojeni përsëri nëse e doni ende." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index 009cdfce9..bc1383d98 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n sekret nuk mund të deshifrohej dhe nuk është në këtë eksport.", + "%n secrets could not be decrypted and are not in this export.": "%n sekrete nuk mund të deshifroheshin dhe nuk janë në këtë eksport.", + "Continue without the secrets that could not be decrypted": "Vazhdo pa sekretet që nuk mund të deshifroheshin", + "This request is no longer available.": "Kjo kërkesë nuk është më e disponueshme.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Zgjidhni cilët kontakte urgjence mund të marrin çelësin tuaj të ri. Shënoni vetëm personat që i keni caktuar vetë dhe tek të cilët ende keni besim: kushdo që ka pasur seancën tuaj mund të ketë shtuar një kontakt të vetin. Kontaktet që nuk i shënoni humbasin qasjen e urgjencës; mund t’i caktoni sërish më pas.", + "{grantee}, waiting period in days: {days}": "{grantee}, periudha e pritjes në ditë: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Nuk i konfirmuat këta kontakte, prandaj qasja e tyre e urgjencës u hoq. Caktojini sërish vetëm nëse jeni i sigurt se i keni shtuar vetë.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Këta kontakte kishin një kërkesë për qasje urgjence në pritje ose të miratuar, prandaj nuk morën çelësin tuaj të ri. Kështu do të dukej një kontakt i shtuar nga dikush tjetër: mos i caktoni sërish, përveç nëse e dini se kërkesa ishte e vërtetë.", + "Invalidated": "E pavlefshme", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ky kontakt kishte një kërkesë për qasje urgjence në pritje ose të miratuar kur ndërruat çelësin, prandaj nuk mori çelësin tuaj të ri. Kështu do të dukej një kontakt i shtuar nga dikush tjetër: mos e caktoni sërish, përveç nëse e dini se kërkesa ishte e vërtetë.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rrotullimi i kyçit u rifillua, prandaj këta kontakte emergjence nuk mund të barteshin dhe aksesi i tyre i emergjencës u hoq. Shtojini përsëri nga Aksesi i emergjencës nëse i doni ende.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rrotullimi i kyçit hoqi %n kontakt emergjence. Kontrolloni Aksesin e emergjencës dhe shtojeni përsëri nëse e doni ende.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rrotullimi i kyçit hoqi %n kontakte emergjence. Kontrolloni Aksesin e emergjencës dhe shtojini përsëri nëse i doni ende.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rrotullimi i kyçit hoqi aksesin e emergjencës së këtij kontakti. Caktojeni përsëri nëse e doni ende." }, "plurals": null } diff --git a/l10n/sr.js b/l10n/sr.js index 67e3d75cf..c836fd5bf 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,43 @@ 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)": "Третирај тајне комплета као компромитоване (означи за ротацију и обавести власнике)", + "%n secret could not be decrypted and is not in this export.": "%n тајна није могла да се дешифрује и није у овом извозу.", + "%n secrets could not be decrypted and are not in this export.": "%n тајни није могло да се дешифрује и нису у овом извозу.", + "Continue without the secrets that could not be decrypted": "Настави без тајни које није било могуће дешифровати", + "This request is no longer available.": "Овај захтев више није доступан.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Изаберите који контакти за хитне случајеве смеју да приме ваш нови кључ. Означите само особе које сте сами одредили и којима и даље верујете: ко је имао вашу сесију, могао је да дода сопствени контакт. Неозначени контакти губе приступ у хитним случајевима; касније их можете поново одредити.", + "{grantee}, waiting period in days: {days}": "{grantee}, период чекања у данима: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Нисте потврдили ове контакте, па је њихов приступ у хитним случајевима уклоњен. Поново их одредите само ако сте сигурни да сте их сами додали.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ови контакти су имали захтев за приступ у хитним случајевима на чекању или одобрен, па нису добили ваш нови кључ. Тако би изгледао контакт који је додао неко други: не одређујте их поново осим ако знате да је захтев био стваран.", + "Invalidated": "Поништено", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Овај контакт је имао захтев за приступ у хитним случајевима на чекању или одобрен када сте променили кључ, па није добио ваш нови кључ. Тако би изгледао контакт који је додао неко други: не одређујте га поново осим ако знате да је захтев био стваран.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротација кључа је настављена, па ови контакти за хитне случајеве нису могли бити пренети и њихов приступ у хитним случајевима је уклоњен. Додајте их поново у одељку Приступ у хитним случајевима ако их још желите.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротација кључа је уклонила %n контакт за хитне случајеве. Проверите Приступ у хитним случајевима и додајте га поново ако га још желите.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротација кључа је уклонила %n контакта за хитне случајеве. Проверите Приступ у хитним случајевима и додајте их поново ако их још желите.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротација кључа је уклонила приступ у хитним случајевима овог контакта. Одредите га поново ако га још желите." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.json b/l10n/sr.json index b7e6cd6c0..ec9c0ac40 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,43 @@ "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)": "Третирај тајне комплета као компромитоване (означи за ротацију и обавести власнике)", + "%n secret could not be decrypted and is not in this export.": "%n тајна није могла да се дешифрује и није у овом извозу.", + "%n secrets could not be decrypted and are not in this export.": "%n тајни није могло да се дешифрује и нису у овом извозу.", + "Continue without the secrets that could not be decrypted": "Настави без тајни које није било могуће дешифровати", + "This request is no longer available.": "Овај захтев више није доступан.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Изаберите који контакти за хитне случајеве смеју да приме ваш нови кључ. Означите само особе које сте сами одредили и којима и даље верујете: ко је имао вашу сесију, могао је да дода сопствени контакт. Неозначени контакти губе приступ у хитним случајевима; касније их можете поново одредити.", + "{grantee}, waiting period in days: {days}": "{grantee}, период чекања у данима: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Нисте потврдили ове контакте, па је њихов приступ у хитним случајевима уклоњен. Поново их одредите само ако сте сигурни да сте их сами додали.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ови контакти су имали захтев за приступ у хитним случајевима на чекању или одобрен, па нису добили ваш нови кључ. Тако би изгледао контакт који је додао неко други: не одређујте их поново осим ако знате да је захтев био стваран.", + "Invalidated": "Поништено", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Овај контакт је имао захтев за приступ у хитним случајевима на чекању или одобрен када сте променили кључ, па није добио ваш нови кључ. Тако би изгледао контакт који је додао неко други: не одређујте га поново осим ако знате да је захтев био стваран.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротација кључа је настављена, па ови контакти за хитне случајеве нису могли бити пренети и њихов приступ у хитним случајевима је уклоњен. Додајте их поново у одељку Приступ у хитним случајевима ако их још желите.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротација кључа је уклонила %n контакт за хитне случајеве. Проверите Приступ у хитним случајевима и додајте га поново ако га још желите.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротација кључа је уклонила %n контакта за хитне случајеве. Проверите Приступ у хитним случајевима и додајте их поново ако их још желите.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротација кључа је уклонила приступ у хитним случајевима овог контакта. Одредите га поново ако га још желите." }, "plurals": null } diff --git a/l10n/sv.js b/l10n/sv.js index 2493381c8..e9c667a44 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n hemlighet kunde inte dekrypteras och finns inte i den här exporten.", + "%n secrets could not be decrypted and are not in this export.": "%n hemligheter kunde inte dekrypteras och finns inte i den här exporten.", + "Continue without the secrets that could not be decrypted": "Fortsätt utan hemligheterna som inte kunde dekrypteras", + "This request is no longer available.": "Den här begäran är inte längre tillgänglig.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Välj vilka nödkontakter som får ta emot din nya nyckel. Markera bara personer som du själv har utsett och fortfarande litar på: den som hade din session kan ha lagt till en egen kontakt. Kontakter du inte markerar förlorar sin nödåtkomst; du kan utse dem igen efteråt.", + "{grantee}, waiting period in days: {days}": "{grantee}, väntetid i dagar: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Du bekräftade inte dessa kontakter, så deras nödåtkomst har tagits bort. Utse dem bara igen om du är säker på att du själv har lagt till dem.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Dessa kontakter hade en väntande eller godkänd begäran om nödåtkomst, så de fick inte din nya nyckel. Så skulle en kontakt som lagts till av någon annan se ut: utse dem inte igen om du inte vet att begäran var äkta.", + "Invalidated": "Ogiltigförklarad", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Den här kontakten hade en väntande eller godkänd begäran om nödåtkomst när du bytte nyckel, så den fick inte din nya nyckel. Så skulle en kontakt som lagts till av någon annan se ut: utse den inte igen om du inte vet att begäran var äkta.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Din nyckelrotation återupptogs, så de här nödkontakterna kunde inte föras över och deras nödåtkomst togs bort. Lägg till dem igen under Nödåtkomst om du fortfarande vill ha dem.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Din nyckelrotation tog bort %n nödkontakt. Kontrollera Nödåtkomst och lägg till den igen om du fortfarande vill ha den.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Din nyckelrotation tog bort %n nödkontakter. Kontrollera Nödåtkomst och lägg till dem igen om du fortfarande vill ha dem.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nyckelrotation tog bort den här kontaktens nödåtkomst. Utse den igen om du fortfarande vill ha den." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index c3ed76219..7ebfed1e7 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n hemlighet kunde inte dekrypteras och finns inte i den här exporten.", + "%n secrets could not be decrypted and are not in this export.": "%n hemligheter kunde inte dekrypteras och finns inte i den här exporten.", + "Continue without the secrets that could not be decrypted": "Fortsätt utan hemligheterna som inte kunde dekrypteras", + "This request is no longer available.": "Den här begäran är inte längre tillgänglig.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Välj vilka nödkontakter som får ta emot din nya nyckel. Markera bara personer som du själv har utsett och fortfarande litar på: den som hade din session kan ha lagt till en egen kontakt. Kontakter du inte markerar förlorar sin nödåtkomst; du kan utse dem igen efteråt.", + "{grantee}, waiting period in days: {days}": "{grantee}, väntetid i dagar: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Du bekräftade inte dessa kontakter, så deras nödåtkomst har tagits bort. Utse dem bara igen om du är säker på att du själv har lagt till dem.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Dessa kontakter hade en väntande eller godkänd begäran om nödåtkomst, så de fick inte din nya nyckel. Så skulle en kontakt som lagts till av någon annan se ut: utse dem inte igen om du inte vet att begäran var äkta.", + "Invalidated": "Ogiltigförklarad", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Den här kontakten hade en väntande eller godkänd begäran om nödåtkomst när du bytte nyckel, så den fick inte din nya nyckel. Så skulle en kontakt som lagts till av någon annan se ut: utse den inte igen om du inte vet att begäran var äkta.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Din nyckelrotation återupptogs, så de här nödkontakterna kunde inte föras över och deras nödåtkomst togs bort. Lägg till dem igen under Nödåtkomst om du fortfarande vill ha dem.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Din nyckelrotation tog bort %n nödkontakt. Kontrollera Nödåtkomst och lägg till den igen om du fortfarande vill ha den.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Din nyckelrotation tog bort %n nödkontakter. Kontrollera Nödåtkomst och lägg till dem igen om du fortfarande vill ha dem.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nyckelrotation tog bort den här kontaktens nödåtkomst. Utse den igen om du fortfarande vill ha den." }, "plurals": null } diff --git a/l10n/tr.js b/l10n/tr.js index cfa9e11e3..8ba3802d3 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,43 @@ 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)", + "%n secret could not be decrypted and is not in this export.": "%n gizli bilginin şifresi çözülemedi ve bu dışa aktarımda yer almıyor.", + "%n secrets could not be decrypted and are not in this export.": "%n gizli bilginin şifresi çözülemedi ve bunlar bu dışa aktarımda yer almıyor.", + "Continue without the secrets that could not be decrypted": "Şifresi çözülemeyen gizli bilgiler olmadan devam et", + "This request is no longer available.": "Bu istek artık kullanılamıyor.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Yeni anahtarınızı hangi acil durum kişilerinin alabileceğini seçin. Yalnızca kendiniz atadığınız ve hâlâ güvendiğiniz kişileri işaretleyin: oturumunuzu ele geçiren kişi kendi kişisini eklemiş olabilir. İşaretlemediğiniz kişiler acil durum erişimini kaybeder; onları daha sonra yeniden atayabilirsiniz.", + "{grantee}, waiting period in days: {days}": "{grantee}, gün cinsinden bekleme süresi: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Bu kişileri onaylamadınız, bu nedenle acil durum erişimleri kaldırıldı. Onları yalnızca kendiniz eklediğinizden eminseniz yeniden atayın.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Bu kişilerin bekleyen veya onaylanmış bir acil durum erişimi isteği vardı, bu nedenle yeni anahtarınızı almadılar. Başka biri tarafından eklenmiş bir kişi böyle görünürdü: isteğin gerçek olduğunu bilmiyorsanız onları yeniden atamayın.", + "Invalidated": "Geçersiz kılındı", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Anahtarınızı değiştirdiğinizde bu kişinin bekleyen veya onaylanmış bir acil durum erişimi isteği vardı, bu nedenle yeni anahtarınızı almadı. Başka biri tarafından eklenmiş bir kişi böyle görünürdü: isteğin gerçek olduğunu bilmiyorsanız onu yeniden atamayın.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Anahtar döndürmeniz sürdürüldü, bu nedenle bu acil durum kişileri aktarılamadı ve acil durum erişimleri kaldırıldı. Hâlâ istiyorsanız onları Acil durum erişimi bölümünden yeniden ekleyin.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Anahtar döndürmeniz %n acil durum kişisini kaldırdı. Acil durum erişimini kontrol edin ve hâlâ istiyorsanız yeniden ekleyin.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Anahtar döndürmeniz %n acil durum kişisini kaldırdı. Acil durum erişimini kontrol edin ve hâlâ istiyorsanız onları yeniden ekleyin.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Anahtar döndürmeniz bu kişinin acil durum erişimini kaldırdı. Hâlâ istiyorsanız onu yeniden atayın." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index 75f393017..c9aa7aa0e 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,43 @@ "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)", + "%n secret could not be decrypted and is not in this export.": "%n gizli bilginin şifresi çözülemedi ve bu dışa aktarımda yer almıyor.", + "%n secrets could not be decrypted and are not in this export.": "%n gizli bilginin şifresi çözülemedi ve bunlar bu dışa aktarımda yer almıyor.", + "Continue without the secrets that could not be decrypted": "Şifresi çözülemeyen gizli bilgiler olmadan devam et", + "This request is no longer available.": "Bu istek artık kullanılamıyor.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Yeni anahtarınızı hangi acil durum kişilerinin alabileceğini seçin. Yalnızca kendiniz atadığınız ve hâlâ güvendiğiniz kişileri işaretleyin: oturumunuzu ele geçiren kişi kendi kişisini eklemiş olabilir. İşaretlemediğiniz kişiler acil durum erişimini kaybeder; onları daha sonra yeniden atayabilirsiniz.", + "{grantee}, waiting period in days: {days}": "{grantee}, gün cinsinden bekleme süresi: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Bu kişileri onaylamadınız, bu nedenle acil durum erişimleri kaldırıldı. Onları yalnızca kendiniz eklediğinizden eminseniz yeniden atayın.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Bu kişilerin bekleyen veya onaylanmış bir acil durum erişimi isteği vardı, bu nedenle yeni anahtarınızı almadılar. Başka biri tarafından eklenmiş bir kişi böyle görünürdü: isteğin gerçek olduğunu bilmiyorsanız onları yeniden atamayın.", + "Invalidated": "Geçersiz kılındı", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Anahtarınızı değiştirdiğinizde bu kişinin bekleyen veya onaylanmış bir acil durum erişimi isteği vardı, bu nedenle yeni anahtarınızı almadı. Başka biri tarafından eklenmiş bir kişi böyle görünürdü: isteğin gerçek olduğunu bilmiyorsanız onu yeniden atamayın.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Anahtar döndürmeniz sürdürüldü, bu nedenle bu acil durum kişileri aktarılamadı ve acil durum erişimleri kaldırıldı. Hâlâ istiyorsanız onları Acil durum erişimi bölümünden yeniden ekleyin.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Anahtar döndürmeniz %n acil durum kişisini kaldırdı. Acil durum erişimini kontrol edin ve hâlâ istiyorsanız yeniden ekleyin.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Anahtar döndürmeniz %n acil durum kişisini kaldırdı. Acil durum erişimini kontrol edin ve hâlâ istiyorsanız onları yeniden ekleyin.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Anahtar döndürmeniz bu kişinin acil durum erişimini kaldırdı. Hâlâ istiyorsanız onu yeniden atayın." }, "plurals": null } diff --git a/l10n/uk.js b/l10n/uk.js index efb114362..0016efe52 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,43 @@ 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)": "Вважати секрети набору скомпрометованими (позначити для ротації та сповістити власників)", + "%n secret could not be decrypted and is not in this export.": "%n секрет не вдалося розшифрувати, і його немає в цьому експорті.", + "%n secrets could not be decrypted and are not in this export.": "%n секретів не вдалося розшифрувати, і їх немає в цьому експорті.", + "Continue without the secrets that could not be decrypted": "Продовжити без секретів, які не вдалося розшифрувати", + "This request is no longer available.": "Цей запит більше не доступний.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Виберіть, які екстрені контакти можуть отримати ваш новий ключ. Позначайте лише людей, яких ви призначили самі й досі їм довіряєте: той, хто мав вашу сесію, міг додати власний контакт. Непозначені контакти втрачають екстрений доступ; згодом ви можете призначити їх знову.", + "{grantee}, waiting period in days: {days}": "{grantee}, період очікування в днях: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Ви не підтвердили ці контакти, тому їхній екстрений доступ видалено. Призначайте їх знову, лише якщо впевнені, що додали їх самі.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ці контакти мали очікуваний або схвалений запит на екстрений доступ, тому не отримали ваш новий ключ. Саме так виглядав би контакт, доданий кимось іншим: не призначайте їх знову, якщо не знаєте, що запит був справжнім.", + "Invalidated": "Анульовано", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Цей контакт мав очікуваний або схвалений запит на екстрений доступ, коли ви змінили ключ, тому не отримав ваш новий ключ. Саме так виглядав би контакт, доданий кимось іншим: не призначайте його знову, якщо не знаєте, що запит був справжнім.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацію ключа було відновлено, тому ці екстрені контакти не вдалося перенести і їхній надзвичайний доступ видалено. Додайте їх знову в розділі «Надзвичайний доступ», якщо вони вам ще потрібні.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротація ключа видалила %n екстрений контакт. Перевірте «Надзвичайний доступ» і додайте його знову, якщо він вам ще потрібен.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротація ключа видалила %n екстрених контактів. Перевірте «Надзвичайний доступ» і додайте їх знову, якщо вони вам ще потрібні.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротація ключа видалила надзвичайний доступ цього контакту. Призначте його знову, якщо він вам ще потрібен." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 10042cf39..d59ba95c4 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,43 @@ "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)": "Вважати секрети набору скомпрометованими (позначити для ротації та сповістити власників)", + "%n secret could not be decrypted and is not in this export.": "%n секрет не вдалося розшифрувати, і його немає в цьому експорті.", + "%n secrets could not be decrypted and are not in this export.": "%n секретів не вдалося розшифрувати, і їх немає в цьому експорті.", + "Continue without the secrets that could not be decrypted": "Продовжити без секретів, які не вдалося розшифрувати", + "This request is no longer available.": "Цей запит більше не доступний.", + "Choose which emergency contacts may receive your new key. Only tick people you designated yourself and still trust: whoever held your session may have added a contact of their own. Contacts you leave unticked lose emergency access; you can designate them again afterwards.": "Виберіть, які екстрені контакти можуть отримати ваш новий ключ. Позначайте лише людей, яких ви призначили самі й досі їм довіряєте: той, хто мав вашу сесію, міг додати власний контакт. Непозначені контакти втрачають екстрений доступ; згодом ви можете призначити їх знову.", + "{grantee}, waiting period in days: {days}": "{grantee}, період очікування в днях: {days}", + "You did not confirm these contacts, so their emergency access was removed. Only designate them again if you are sure you added them yourself.": "Ви не підтвердили ці контакти, тому їхній екстрений доступ видалено. Призначайте їх знову, лише якщо впевнені, що додали їх самі.", + "These contacts had an emergency-access request pending or approved, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Ці контакти мали очікуваний або схвалений запит на екстрений доступ, тому не отримали ваш новий ключ. Саме так виглядав би контакт, доданий кимось іншим: не призначайте їх знову, якщо не знаєте, що запит був справжнім.", + "Invalidated": "Анульовано", + "This contact had an emergency-access request pending or approved when you rotated your key, so they did not receive your new key. That is how a contact added by someone else would look: do not designate them again unless you know the request was genuine.": "Цей контакт мав очікуваний або схвалений запит на екстрений доступ, коли ви змінили ключ, тому не отримав ваш новий ключ. Саме так виглядав би контакт, доданий кимось іншим: не призначайте його знову, якщо не знаєте, що запит був справжнім.", + "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацію ключа було відновлено, тому ці екстрені контакти не вдалося перенести і їхній надзвичайний доступ видалено. Додайте їх знову в розділі «Надзвичайний доступ», якщо вони вам ще потрібні.", + "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротація ключа видалила %n екстрений контакт. Перевірте «Надзвичайний доступ» і додайте його знову, якщо він вам ще потрібен.", + "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротація ключа видалила %n екстрених контактів. Перевірте «Надзвичайний доступ» і додайте їх знову, якщо вони вам ще потрібні.", + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротація ключа видалила надзвичайний доступ цього контакту. Призначте його знову, якщо він вам ще потрібен." }, "plurals": null } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 8577679ab..9260c8356 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -26,6 +26,7 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use Psr\Log\LoggerInterface; /** * Main application class for the Keepiq Nextcloud app. @@ -94,7 +95,7 @@ public function __construct() { * there is no container to resolve an adapter from yet, and declaring a * typed dependency on a possibly-absent foreign class would 500 every * route (a param type is a class reference the router reflects over). - * OpenRegisterAutoloader::register() is static for the same reason. + * OpenRegisterAutoloader::bootstrapAppHost() is static for the same reason. */ public function register(IRegistrationContext $context): void { include_once __DIR__ . '/../../vendor/autoload.php'; @@ -115,8 +116,9 @@ public function register(IRegistrationContext $context): void { // // LOAD-ORDER HAZARD (measured, not theoretical). OC_App::getEnabledApps() // sort()s the app list, and Coordinator::registerApps() walks THAT sorted - // list calling OC_App::registerAutoloading($appId) and then $app->register() - // for one app at a time. So every app registers before the PSR-4 prefix of + // list registering one app's autoloader (private API: OC_App's up to + // NC 34, AppManager's from 35) and then calling $app->register(), one + // app at a time. So every app registers before the PSR-4 prefix of // every alphabetically-LATER app exists: `keepiq` < `openregister`, so // OCA\OpenRegister\ is not autoloadable at this point on a perfectly // healthy instance. @@ -128,26 +130,36 @@ public function register(IRegistrationContext $context): void { // enabled and kept serving requests: nothing in the UI, and nothing in the // app itself, reported that half its wiring was missing. // - // OpenRegisterAutoloader::register() puts OpenRegister's prefix on the - // autoloader ourselves, which is exactly what Nextcloud will do a few - // iterations later. It never throws; it returns false when OpenRegister is - // absent, and the class_exists() guard below then skips the AppHost - // plumbing. - OpenRegisterAutoloader::register(); - - // The class_exists() guard MUST stay in this method: it is also the - // assertion psalm relies on to accept the Bootstrap::register() call - // below, and psalm does not carry that narrowing across a call. - if (class_exists(Bootstrap::class) === true) { - try { + // OpenRegisterAutoloader puts OpenRegister's prefix on the autoloader + // ourselves, which is exactly what Nextcloud will do a few iterations + // later, and then runs the AppHost wiring below. bootstrapAppHost() is the + // whole of that wiring, so every branch of it is unit-tested there rather + // than here, where Application cannot be constructed without a container. + // It never throws. An absent or disabled OpenRegister skips the AppHost + // plumbing quietly; anything else (no lib/, no loadable Bootstrap, a + // throwing or broken Bootstrap) is recorded and logged from boot(). This + // app's own listeners and services below MUST register either way. + // + // Gate-64 — apphost-prelude exclude This app HAS a prelude, OpenRegisterAutoloader + // — but gate-64 matches only `registerAutoloading(...)` naming + // 'openregister', which is `\OC_App::registerAutoloading()`. That is + // PRIVATE API and Nextcloud 35 REMOVED it, which is the defect this + // app just fixed (keepiq#712): the call threw, the prelude's catch-all + // returned false, the guard answered false, and every AppHost endpoint + // returned 500. NC 35 moved the method to `OC\App\AppManager`, also + // private and not on `OCP\App\IAppManager`, so there is no public API + // the gate's pattern can be satisfied with. The prelude now does what + // Nextcloud does — a PSR-4 prefix over the app's lib/, via + // spl_autoload_register and the public IAppManager::getAppPath(). The + // gate's intent is met; its pattern cannot be. Tracked in + // ConductionNL/.github#791: gate-64 should accept a prelude that + // registers the prefix by any means, and stop mandating a method that + // no longer exists. + OpenRegisterAutoloader::bootstrapAppHost( + bootstrap: static function () use ($context): void { Bootstrap::register($context, self::APP_ID, ['namespace' => 'OCA\\Keepiq']); - } catch (\Throwable) { - // AppHost present but unloadable: skip the generic plumbing; - // Keepiq's own listeners and services MUST still register. No - // logger is resolvable this early, so the skip is silent — - // /api/health surfaces the degraded AppHost state instead. } - } + ); // ORDER MATTERS here: a registerService() for an id the AppHost engine // already aliased only wins when it runs after that call. @@ -176,13 +188,21 @@ public function register(IRegistrationContext $context): void { * * @param IBootContext $context The boot context * + * All wiring happens in register(). The one thing done here is reporting + * why the OpenRegister AppHost wiring fell through to the degraded path, + * because register() runs before this app's container can inject a logger + * and must never throw. + * * @return void * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) $context is mandated by - * OCP\AppFramework\Bootstrap\IBootstrap::boot(), which this class implements. - * All wiring happens in register(); there is nothing to do at boot time, but - * the method and its parameter cannot be dropped from the interface. + * @SuppressWarnings(PHPMD.StaticAccess) OpenRegisterAutoloader is a static + * prelude by design: it runs before this app's container exists. + * + * @spec openspec/specs/apphost-adoption/spec.md#requirement-apphost-prelude-registers-openregister-with-public-api-only */ public function boot(IBootContext $context): void { + OpenRegisterAutoloader::reportFailure( + logger: $context->getServerContainer()->get(LoggerInterface::class) + ); }//end boot() }//end class 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/OpenRegisterAutoloader.php b/lib/AppInfo/OpenRegisterAutoloader.php index 2db54db79..6beaae8b7 100644 --- a/lib/AppInfo/OpenRegisterAutoloader.php +++ b/lib/AppInfo/OpenRegisterAutoloader.php @@ -22,15 +22,22 @@ namespace OCA\Keepiq\AppInfo; +use OCP\App\AppPathNotFoundException; +use OCP\App\IAppManager; +use Psr\Log\LoggerInterface; +use RuntimeException; + /** * Registers OpenRegister's autoload prefix before AppHost is referenced. * * ## Why this is needed (ADR-040) * - * `OC_App::getEnabledApps()` does `sort($apps)`, and - * `Coordinator::registerApps()` walks THAT sorted list calling - * `OC_App::registerAutoloading($appId, $path)` and then `$app->register()` for - * one app at a time. So every app's `register()` runs BEFORE the PSR-4 prefix + * The enabled apps are walked in SORTED order: + * `Coordinator::registerApps()` registers one app's autoloader and then calls + * that app's `register()`, before moving to the next. (The autoloader call is + * `OC_App::registerAutoloading()` up to Nextcloud 34 and + * `AppManager::registerAutoloading()` from 35; both are private, and the 34 one + * no longer exists.) So every app's `register()` runs BEFORE the PSR-4 prefix * of every alphabetically-LATER app exists. * * `keepiq` sorts before `openregister`, so `OCA\OpenRegister\` is NOT @@ -45,6 +52,8 @@ * container, so an inline prelude is unreachable from a unit test. Here the * degraded-path contract — "this NEVER throws, whatever the instance looks * like" — is directly assertable, and it is asserted. + * + * @spec openspec/specs/apphost-adoption/spec.md#requirement-apphost-prelude-registers-openregister-with-public-api-only */ final class OpenRegisterAutoloader { @@ -53,6 +62,56 @@ final class OpenRegisterAutoloader { */ private const OPENREGISTER_APP_ID = 'openregister'; + /** + * The PSR-4 prefix this prelude resolves, trailing separator included. + */ + private const OPENREGISTER_NAMESPACE = 'OCA\\OpenRegister\\'; + + /** + * The AppHost entry point, as a string so naming it autoloads nothing. + */ + private const BOOTSTRAP_CLASS = 'OCA\\OpenRegister\\AppHost\\Bootstrap'; + + /** + * Whether the prefix is already on the autoloader. + * + * `spl_autoload_register()` has no early-return of its own, so without this + * every leaf calling the prelude would stack another closure on the loader + * for the life of the request. + * + * @var boolean + */ + private static bool $registered = false; + + /** + * The registered autoload callable, kept so it can be removed again. + * + * Registering an autoloader is a PROCESS-WIDE side effect. In a unit-test + * run that outlives one test: every later `class_exists()` for an absent + * class runs this closure, and PHPUnit's strict coverage metadata then + * reports unrelated tests as risky for "executing code not listed as + * covered or used" — which is true, and is the suite telling us a global + * was left behind. Measured on keepiq#712 (ConnectionReporterTest). The + * handle lets {@see unregister()} put the process back as it found it. + * + * @var callable|null + */ + private static $loader = null; + + /** + * Why the last register() call returned false, when it was not a clean "absent". + * + * `register()` runs before any logger can be injected and must never + * throw, so it cannot report a failure itself. It records it here and + * {@see reportFailure()} logs it from `Application::boot()`. Without this, + * every failure collapsed into an unlogged false — which is how the + * Nextcloud 35 removal of `OC_App::registerAutoloading()` turned into + * AppHost 500s with nothing in the log. + * + * @var \Throwable|null + */ + private static ?\Throwable $failure = null; + /** * Register OpenRegister's PSR-4 prefix on the composer autoloader. * @@ -61,39 +120,315 @@ final class OpenRegisterAutoloader { * answers FALSE, not "not yet loaded", and a FALSE is indistinguishable * from OpenRegister being absent. * - * `OC_App::registerAutoloading()` touches only the autoloader and is - * idempotent: it early-returns on an `$alreadyRegistered` key, so calling - * this more than once is free. + * ## No private API, and no booting OpenRegister + * + * This used to call `\OC_App::registerAutoloading()`. That is private API — + * `lib/private/legacy/OC_App.php` — and **Nextcloud 35 removed the method**. + * The `\Error` landed in the catch below, this returned false, the caller's + * `class_exists()` probe answered false, and the AppHost plumbing was + * silently skipped: `/api/health` and `/api/metrics` returned 500 with an + * HTML error page on a healthy instance (keepiq#712, nine Newman + * assertions). NC 35 moved the method to `OC\App\AppManager`, which is also + * private — it is not on `OCP\App\IAppManager` — so porting it would buy + * one version and re-arm the same trap. + * + * `IAppManager::loadApp('openregister')` IS public, and is still not used + * here. It calls `Coordinator::bootApp()`, which would boot OpenRegister + * before its own `register()` has run — and `bootApp()` sets + * `bootedApps[..] = true` BEFORE booting, so a throw there is caught, logged + * once, and OpenRegister is never booted again for that request. Its + * `boot()` dispatches the deep-link registration event and boots the + * integration providers, leaf registry, object-source providers and + * federation. Trading this app's 500 for OpenRegister silently losing half + * its boot, instance-wide, is not a trade worth making. + * + * So this does what Nextcloud does, with public API and plain PHP. For an + * app shipping `vendor/autoload.php` rather than `composer/autoload.php` — + * which is OpenRegister — `AppManager::registerAutoloading()` reduces to + * + * addPsr4('OCA\\OpenRegister\\', $path . '/lib/', true); + * + * a PSR-4 prefix pointing at `lib/`, and nothing else. `spl_autoload_register` + * expresses exactly that. The path comes from `IAppManager::getAppPath()`, + * which is public and correct across multiple `apps_paths` — the reason a + * hardcoded `__DIR__ . '/../../../openregister'` is not acceptable here. * - * Deliberately NOT `IAppManager::loadApp('openregister')`: that marks - * OpenRegister loaded and calls `Coordinator::bootApp()`, booting it before - * its own `register()` has run. + * OpenRegister's own `vendor/autoload.php` is deliberately NOT required: + * that would pull its entire third-party dependency tree into this app's + * process, where a version differing from ours would win on a first-come + * basis. Nextcloud does not do that for this app either. Class-level type + * hints are not resolved until used, so a PSR-4 prefix over `lib/` is + * sufficient to reference `AppHost\Bootstrap`. + * + * `getAppPath()` is a pure path lookup and does not consult enabled state, + * so enabled state is checked first: Nextcloud only autoloads enabled apps, + * and an admin who disables OpenRegister must not still get its code loaded + * into this app's process. + * + * @param IAppManager|null $appManager The app manager; resolved from the + * server container when null. Injectable + * for tests only. * * @return bool True when the prefix is registered, false when OpenRegister * is absent, disabled, or otherwise unresolvable — in which * case the caller MUST fall through to its degraded path. * - * @SuppressWarnings(PHPMD.StaticAccess) OC_App is Nextcloud's legacy - * bootstrap class. There is no OCP interface for registering another app's - * autoloader, and this runs at the composition root where no container is - * available to resolve an adapter from. + * @SuppressWarnings(PHPMD.StaticAccess) `\OCP\Server::get()` is the public + * service locator, and this runs at the composition root — there is no + * container to inject, which is the whole reason a prelude exists. * - * @spec openspec/specs/apphost-adoption/spec.md + * @spec openspec/specs/apphost-adoption/spec.md#requirement-apphost-prelude-registers-openregister-with-public-api-only */ - public static function register(): bool { + public static function register(?IAppManager $appManager = null): bool { + self::$failure = null; + try { - $appManager = \OCP\Server::get(\OCP\App\IAppManager::class); - $path = $appManager->getAppPath(self::OPENREGISTER_APP_ID); - \OC_App::registerAutoloading(self::OPENREGISTER_APP_ID, $path); + $appManager ??= \OCP\Server::get(IAppManager::class); + + // Checked BEFORE the short-circuit: under a worker (FrankenPHP on + // NC 35) this static outlives the request, and an OpenRegister + // disabled since the first registration must not still be wired. + if ($appManager->isEnabledForAnyone(self::OPENREGISTER_APP_ID) === false) { + // Absent or disabled: the expected degraded path, and quiet. Take + // the loader off the chain as well, so OpenRegister classes not + // yet loaded stop being autoloadable through this app. + self::removeLoader(); + return false; + } + + if (self::$registered === true) { + return true; + } + + $path = rtrim($appManager->getAppPath(self::OPENREGISTER_APP_ID), '/'); + + // Enabled but without lib/ (a partial deploy, a packaging change, + // wrong permissions) is neither absent nor disabled, and Nextcloud + // logs nothing for it either. Registering a prefix over nothing would + // only hide it, so record it for reportFailure() instead. + if (is_dir($path . '/lib') === false) { + self::$failure = new RuntimeException( + sprintf('OpenRegister is enabled but %s/lib is not a directory', $path) + ); + return false; + } + + self::$loader = static function (string $class) use ($path): void { + self::loadClass(appPath: $path, class: $class); + }; + + spl_autoload_register(self::$loader); + + self::$registered = true; return true; - } catch (\Throwable) { - // OpenRegister absent, disabled, or the server container is not up - // (unit tests). The caller's class_exists() guard then skips the - // AppHost plumbing. Never rethrow: an exception escaping here would - // abort the caller's entire register(), which is the exact defect - // this prelude exists to prevent. + } catch (\Throwable $e) { + // OpenRegister enabled but not on disk, or the server container is + // not up (unit tests), or something unexpected. The caller then skips + // the AppHost plumbing. Never rethrow: an exception + // escaping here would abort the caller's entire register(), which is + // the exact defect this prelude exists to prevent. Record it instead, + // for reportFailure() to log once a logger is available. + self::$failure = $e; return false; } }//end register() + + /** + * Remove the autoloader again, for tests that must not leak it. + * + * Production never calls this: the prefix is wanted for the life of the + * request. A test suite runs many tests in one process, so a test that + * exercises {@see register()} has to hand the process back unchanged or it + * changes the behaviour of every test after it. + * + * @return void + * + * @spec openspec/specs/apphost-adoption/spec.md#requirement-apphost-prelude-registers-openregister-with-public-api-only + */ + public static function unregister(): void { + self::removeLoader(); + self::$failure = null; + + }//end unregister() + + /** + * Wire OpenRegister's AppHost plumbing into the caller, or record why not. + * + * This is the whole of the caller's AppHost wiring, in one place a test can + * reach: `Application::register()` cannot be constructed without a DI + * container, so every branch that lived there was untestable. The prelude + * runs first. When it refuses, nothing runs. When it registered, the + * Bootstrap class must be loadable (an OpenRegister older than AppHost, or + * a partial deploy, is not), and then the caller's closure runs. Any + * failure, including a ParseError from a truncated Bootstrap.php that the + * class check itself includes, is recorded for {@see reportFailure()} and + * never escapes: an exception here would abort the caller's register(). + * + * @param callable():void $bootstrap Calls `AppHost\Bootstrap::register()`. + * @param IAppManager|null $appManager Passed to {@see register()}; for tests only. + * @param string $bootstrapClass The class that must be loadable; for tests only. + * + * @return void + * + * @spec openspec/specs/apphost-adoption/spec.md#requirement-apphost-prelude-registers-openregister-with-public-api-only + */ + public static function bootstrapAppHost( + callable $bootstrap, + ?IAppManager $appManager = null, + string $bootstrapClass = self::BOOTSTRAP_CLASS, + ): void { + if (self::register(appManager: $appManager) === false) { + return; + } + + try { + if (class_exists($bootstrapClass) === false) { + throw new RuntimeException( + sprintf( + 'OpenRegister is enabled but %s is not loadable (too old for AppHost, or an incomplete deploy)', + $bootstrapClass + ) + ); + } + + $bootstrap(); + } catch (\Throwable $e) { + self::recordFailure(failure: $e); + } + + }//end bootstrapAppHost() + + /** + * Take this prelude's loader off the autoload chain, if it is on it. + * + * @return void + */ + private static function removeLoader(): void { + if (self::$loader !== null) { + spl_autoload_unregister(self::$loader); + self::$loader = null; + } + + self::$registered = false; + + }//end removeLoader() + + /** + * Record a failure of the AppHost wiring that follows this prelude. + * + * `Application::register()` catches a throwing `AppHost\Bootstrap::register()` + * for the same reason this class catches its own failures, and cannot log + * there either. Handing it here lets {@see reportFailure()} cover both. + * + * @param \Throwable $failure What went wrong. + * + * @return void + * + * @spec openspec/specs/apphost-adoption/spec.md#requirement-apphost-prelude-registers-openregister-with-public-api-only + */ + public static function recordFailure(\Throwable $failure): void { + self::$failure = $failure; + + }//end recordFailure() + + /** + * Log why the AppHost wiring fell through to the degraded path. + * + * Called from `Application::boot()`, where the logger is resolvable. A + * disabled or absent OpenRegister records nothing and stays quiet, and so + * does one that is enabled but not on disk ({@see AppPathNotFoundException}), + * which Nextcloud's Coordinator already logs. Anything else leaves one + * warning. The failure is cleared once reported, so it is logged once per + * request: a persistent failure logs on every request until it is fixed. + * + * @param LoggerInterface $logger The logger to report to. + * + * @return void + * + * @spec openspec/specs/apphost-adoption/spec.md#requirement-apphost-prelude-registers-openregister-with-public-api-only + */ + public static function reportFailure(LoggerInterface $logger): void { + $failure = self::$failure; + self::$failure = null; + + if ($failure === null || $failure instanceof AppPathNotFoundException) { + return; + } + + $logger->warning( + 'OpenRegister AppHost wiring was skipped: {reason}', + ['reason' => $failure->getMessage(), 'exception' => $failure] + ); + + }//end reportFailure() + + /** + * Resolve and include one class, if it is ours and present on disk. + * + * The body of the registered closure, lifted out so it is reachable from a + * test. Inside the closure it could only ever run when PHP happened to + * autoload an `OCA\OpenRegister\…` name during the suite — which no test + * can arrange and which therefore went unexercised, while being the part + * that actually does the work. + * + * Silent on a miss, deliberately: an autoloader is asked about every class + * PHP cannot already see, most of which belong to somebody else. Throwing, + * or even warning, would make this app noisy about other people's lookups. + * + * @param string $appPath Absolute path to the openregister app. + * @param string $class The class being resolved. + * + * @return void + * + * @spec openspec/specs/apphost-adoption/spec.md#requirement-apphost-prelude-registers-openregister-with-public-api-only + */ + private static function loadClass(string $appPath, string $class): void { + $file = self::classFile(appPath: $appPath, class: $class); + if ($file === null) { + return; + } + + if (is_file($file) === true) { + require_once $file; + } + + }//end loadClass() + + /** + * The file a PSR-4 class name maps to, or null when it is not ours. + * + * Split out of the closure so the mapping is directly assertable. It is the + * part of this class Nextcloud used to own — `addPsr4()` did it — and now + * does not, so it is the part most worth pinning: a missing trailing + * separator, an off-by-one in the slice, or forgetting that PHP namespace + * separators are backslashes while paths are not, each produce an autoloader + * that silently resolves nothing. And "resolves nothing" is + * indistinguishable from "OpenRegister is absent" at the call site. + * + * Returning null rather than a path for a foreign class matters: an + * autoloader that answers for names it does not own can shadow another + * loader that would have resolved them. + * + * @param string $appPath Absolute path to the openregister app, no trailing slash. + * @param string $class The fully qualified class name being resolved. + * + * @return string|null The candidate file, or null when the class is not OpenRegister's. + * + * @spec openspec/specs/apphost-adoption/spec.md#requirement-apphost-prelude-registers-openregister-with-public-api-only + */ + private static function classFile(string $appPath, string $class): ?string { + $prefix = self::OPENREGISTER_NAMESPACE; + if (str_starts_with($class, $prefix) === false) { + return null; + } + + $relative = substr($class, strlen($prefix)); + if ($relative === '') { + return null; + } + + return $appPath . '/lib/' . str_replace('\\', '/', $relative) . '.php'; + + }//end classFile() }//end class 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..9013b0a1d 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; @@ -37,15 +40,24 @@ /** * Wires the EncryptionSuite lifecycle listener graph. * - * The three suite events fan out to more than one listener each, and the - * ORDER of the bindings is not significant — Nextcloud's dispatcher invokes - * every registered listener for an event and a failure in one is contained by - * that listener, not by this registration. + * The three suite events fan out to more than one listener each. Nextcloud's + * dispatcher invokes every registered listener for an event, and a failure in + * one is contained by that listener, not by this registration. The ORDER is + * not significant, with one exception on the revoke event: the compromise + * cascade reads the ShareTargets that EncryptionSuiteRevokedListener deletes, + * so it is registered at a higher priority to run first (keepiq#802). * - * 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 +82,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 +102,21 @@ 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). + // Priority 10 so it runs BEFORE EncryptionSuiteRevokedListener (priority + // 0), which deletes the revoked user's inbound ShareTargets. The cascade + // resolves each shared copy's source owner through those rows; run after + // the sweep it always missed and warned the revoked user instead of the + // owners who have to rotate (keepiq#802). + $context->registerEventListener( + event: EncryptionSuiteRevokedEvent::class, + listener: SuiteCompromiseOnRevokeListener::class, + priority: 10 + ); + // 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..2ab4c463b --- /dev/null +++ b/lib/Attribute/VaultKeyProofRequired.php @@ -0,0 +1,94 @@ + + * @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; + * 'migrationOldSuite' / 'migrationNewSuite' — the old + * or new suite of the migration named by route `id`. + * @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..e00edab45 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; @@ -79,7 +81,7 @@ public function index(): JSONResponse { return new JSONResponse( data: array_map( - static fn ($c) => $c->jsonSerialize(), + static fn ($c) => $c->jsonSerializeForGrantor(), $this->service->listForGrantor(grantorUserId: $userId) ) ); @@ -142,6 +144,13 @@ public function granteeCertificate(string $granteeUserId): JSONResponse { * Designate (or re-establish) an emergency contact. The recovery envelope is * built in the grantor's browser and supplied as opaque ciphertext. * + * Guarded by a vault-key proof (keepiq#800). This is an upsert: it creates + * a contact that a later rotation escrows the NEW private key to, and it + * overwrites the envelope of an existing contact. With a session alone + * either one would let a stolen session plant itself as a grantee, or + * destroy break-glass by overwriting an envelope, which the proof on + * destroy() exists to prevent. + * * @param string $granteeUserId The grantee Nextcloud user ID * @param int $waitPeriodDays The wait period (1|3|7|30) * @param string $recoveryEnvelope The grantee-encrypted recovery envelope @@ -151,9 +160,10 @@ public function granteeCertificate(string $granteeUserId): JSONResponse { * * @return JSONResponse * - * @spec openspec/changes/add-emergency-access/specs/emergency-access/spec.md#requirement-designate-emergency-contact + * @spec openspec/changes/harden-vault-key-material-guards/specs/emergency-access/spec.md#requirement-designate-emergency-contact */ #[NoAdminRequired] + #[VaultKeyProofRequired(binds: ['granteeUserId', 'waitPeriodDays', 'recoveryEnvelope'], purpose: VaultKeyProofService::PURPOSE_EMERGENCY_DESIGNATE)] public function create( string $granteeUserId, int $waitPeriodDays, @@ -192,6 +202,7 @@ public function create( * @spec openspec/changes/add-emergency-access/specs/emergency-access/spec.md#requirement-revoke-emergency-contact */ #[NoAdminRequired] + #[VaultKeyProofRequired(purpose: VaultKeyProofService::PURPOSE_EMERGENCY_DESTROY, binds: ['id'])] 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..1e7e7aff7 100644 --- a/lib/Controller/EncryptionSuiteController.php +++ b/lib/Controller/EncryptionSuiteController.php @@ -25,12 +25,17 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Exception\ConflictException; +use OCA\Keepiq\Exception\SuiteMigrationInProgressException; +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 +44,20 @@ /** * 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. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Same cause: the aggregate + * is the sum of small endpoints that each map their own exceptions to a + * status. It reached the threshold when both revoke paths gained the 409 for + * a suite that is part of an in-progress migration (keepiq#803). Splitting + * the two revoke endpoints off would duplicate validateOwnership() and the + * emergency-access safeguard, not remove any branch. */ class EncryptionSuiteController extends OCSController { /** @@ -48,6 +67,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 +78,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 +253,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 +284,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,8 +341,38 @@ public function revoke(string $id, string $reason): JSONResponse { // already call this same helper; revoke() did not. $this->validateOwnership(suite: $this->suiteService->getSuite($id)); + // Not while the suite is part of an in-progress migration: revoking + // either end strands it (keepiq#803). + $this->migrationService->assertNoMigrationInProgress(suiteId: $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 (SuiteMigrationInProgressException $e) { + return new JSONResponse( + data: ['error' => 'migration_in_progress', 'message' => $e->getMessage()], + statusCode: Http::STATUS_CONFLICT + ); } catch (RuntimeException $e) { return new JSONResponse( data: ['message' => $e->getMessage()], @@ -328,6 +412,151 @@ 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 { + // Not while the suite is part of an in-progress migration: revoking + // either end strands it (keepiq#803). Checked before anything else. + // A COMPROMISE force-revoke is the exception: there the migration is + // ended below instead, or whoever is being contained could block the + // containment for good by leaving a migration open. + if ($markCompromised === false) { + $this->migrationService->assertNoMigrationInProgress(suiteId: $id); + } + + // 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); + } + + $data += $this->endMigrationForCompromise(suiteId: $id, reason: $reason, adminUid: $adminUid); + + return new JSONResponse(data: $data); + } catch (SuiteMigrationInProgressException $e) { + return new JSONResponse( + data: ['error' => 'migration_in_progress', 'message' => $e->getMessage()], + statusCode: Http::STATUS_CONFLICT + ); + } 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() + + /** + * Revoke the other end of the suite's in-progress migration, then end it. + * + * Part of a compromise force-revoke. The other end is revoked as + * compromised too: during a compromise either end may be the one the + * attacker controls (keepiq#809 review). The migration is terminated LAST, + * so if revoking the other end fails, a retry of the force-revoke still + * finds the open migration and finishes the job. + * + * @param string $suiteId The suite just force-revoked + * @param string $reason The admin's reason, reused for the other end + * @param string $adminUid The acting administrator + * + * @return array `terminatedMigration` and `alsoRevokedSuite`, or empty when no migration was open + * + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-a-suite-in-an-in-progress-migration-cannot-be-revoked + */ + private function endMigrationForCompromise(string $suiteId, string $reason, string $adminUid): array { + $migration = $this->migrationService->findInProgressForSuite(suiteId: $suiteId); + if ($migration === null) { + return []; + } + + $otherId = $migration->getOldSuiteId(); + if ($otherId === $suiteId) { + $otherId = $migration->getNewSuiteId(); + } + + $this->suiteService->revokeSuite( + id: $otherId, + reason: $reason, + revokedBy: $adminUid, + markCompromised: true, + emergencyContactsDestroyed: $this->emergencyService->countUsableForGrantorSuite($otherId), + ); + + $this->migrationService->terminateForCompromise(migration: $migration); + + return ['terminatedMigration' => $migration->getId(), 'alsoRevokedSuite' => $otherId]; + + }//end endMigrationForCompromise() + /** * Initiate compromise recovery: create new suite and migration record. * @@ -342,6 +571,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 +676,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/GdprController.php b/lib/Controller/GdprController.php index d1920b096..6f3e16939 100644 --- a/lib/Controller/GdprController.php +++ b/lib/Controller/GdprController.php @@ -31,9 +31,11 @@ namespace OCA\Keepiq\Controller; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Attribute\VaultKeyProofRequired; use OCA\Keepiq\Event\GdprExportPerformedEvent; use OCA\Keepiq\Service\AccountDeletionService; use OCA\Keepiq\Service\GdprService; +use OCA\Keepiq\Service\VaultKeyProofService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -114,19 +116,22 @@ public function metadata(): JSONResponse { /** * Delete all of the session user's Keepiq data (GDPR Art. 17). * - * Gated by the typed confirmation phrase in the request body. The - * master-password re-authentication is enforced client-side (proof of - * knowledge): the server cannot verify the master password under the - * always-E2E model (ADR-003), so it never sees it. Returns the per-entity - * DeletionReport counts. + * Gated by the typed confirmation phrase AND a vault-key proof. The phrase + * guards against a slip; the proof guards against a stolen session, which + * could otherwise wipe every secret, suite and migration in one request. The + * server never sees the master password (ADR-003): the proof is a signature + * made with the private key it unlocks. A user without an active suite cannot + * make one, and is deleted through the Nextcloud account instead + * (UserDeletedListener). Returns the per-entity DeletionReport counts. * * @NoAdminRequired * * @return JSONResponse * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/changes/harden-vault-key-material-guards/specs/gdpr-compliance/spec.md#requirement-account-data-deletion */ #[NoAdminRequired] + #[VaultKeyProofRequired(binds: ['confirmation'], subject: 'active', purpose: VaultKeyProofService::PURPOSE_DELETE_ACCOUNT_DATA)] public function deleteAccountData(): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { diff --git a/lib/Controller/MigrationController.php b/lib/Controller/MigrationController.php index 993ddfdd1..ade99a5eb 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,13 +46,29 @@ /** * 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 { + /** + * The request parameters a re-envelope proof commits to, in signing order + * (reEnvelopeEmergencyContact). A constant so the attribute fits one line. + * + * @var string[] + */ + private const ENVELOPE_BINDS = ['id', 'contactId', 'recoveryEnvelope', 'granteeSuiteId']; + /** * Constructor for MigrationController. * @@ -55,6 +76,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 +86,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 +137,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 +200,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 +495,97 @@ 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. + * + * Guarded by a vault-key proof over the migration's NEW key (keepiq#801, + * #804 review): it overwrites a contact's envelope, so with a session alone + * it could destroy break-glass the same way an unguarded destroy() could. + * It is the new key, not the old one, because every migration is a + * compromise recovery and the old password may be the leaked one. The new + * key is held by the party who started the migration. That closes the case + * of a leaked password used against a rotation the owner started, but not a + * rotation the attacker started: starting one (compromiseRecovery) is proven + * with the ACTIVE key, so a holder of the session and the old password can + * start it with a key pair of their own and pass this proof too. + * + * @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] + #[VaultKeyProofRequired(purpose: VaultKeyProofService::PURPOSE_EMERGENCY_RE_ENVELOPE, binds: self::ENVELOPE_BINDS, subject: 'migrationNewSuite')] + 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/SecretController.php b/lib/Controller/SecretController.php index fb57b86b6..862bfba3b 100644 --- a/lib/Controller/SecretController.php +++ b/lib/Controller/SecretController.php @@ -202,7 +202,11 @@ public function create( ], userId: $userId ); - } catch (SuiteBlockedException $e) { + } catch (NotFoundException $e) { + // The folder named in the request does not exist (keepiq#795). + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_NOT_FOUND); + } catch (ForbiddenException|SuiteBlockedException $e) { + // ForbiddenException: the folder belongs to another user (keepiq#795). return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); } catch (WriteLockedException $e) { return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: self::STATUS_LOCKED); 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..f3f5286b8 100644 --- a/lib/Db/EmergencyContact.php +++ b/lib/Db/EmergencyContact.php @@ -217,10 +217,29 @@ 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'), 'updatedAt' => $this->updatedAt?->format('c'), ]; }//end jsonSerialize() + + /** + * Serialize for the grantor's own contact list: the management shape plus + * why the contact was invalidated, which the view needs to decide whether + * to offer Re-establish (#804 review). Not part of jsonSerialize(), which + * also feeds the grantee's incoming list: that the grantor chose not to + * carry a contact across a rotation is the grantor's to know. + * + * @return array + * + * @spec openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md#requirement-envelope-invalidation-on-key-change + */ + public function jsonSerializeForGrantor(): array { + return array_merge( + $this->jsonSerialize(), + ['invalidatedReason' => $this->invalidatedReason] + ); + }//end jsonSerializeForGrantor() }//end class diff --git a/lib/Db/SecretRequestMapper.php b/lib/Db/SecretRequestMapper.php index c8930ba8f..5b9009143 100644 --- a/lib/Db/SecretRequestMapper.php +++ b/lib/Db/SecretRequestMapper.php @@ -276,6 +276,29 @@ public function lockByEncryptionSuiteId(string $encryptionSuiteId): int { return $qb->executeStatement(); }//end lockByEncryptionSuiteId() + /** + * Unlock the requests locked on a suite, leaving them on that suite. + * + * For a migration that ended without moving anything onto the new suite + * (an owner's abort, or a compromise force-revoke that terminated it): the + * requests go back to pending where they were. + * + * @param string $encryptionSuiteId The suite the requests are locked on + * + * @return int The number of requests unlocked + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-suite-migration + */ + public function unlockByEncryptionSuiteId(string $encryptionSuiteId): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('status', $qb->createNamedParameter(SecretRequest::STATUS_PENDING)) + ->where($qb->expr()->eq('encryption_suite_id', $qb->createNamedParameter($encryptionSuiteId))) + ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(SecretRequest::STATUS_LOCKED))); + + return $qb->executeStatement(); + }//end unlockByEncryptionSuiteId() + /** * Re-point all locked requests bound to the old EncryptionSuite at * the new EncryptionSuite + return them to pending. diff --git a/lib/Event/Audit/AuditEventTypes.php b/lib/Event/Audit/AuditEventTypes.php index 329b1ed7a..91c3ec515 100644 --- a/lib/Event/Audit/AuditEventTypes.php +++ b/lib/Event/Audit/AuditEventTypes.php @@ -100,6 +100,10 @@ final class AuditEventTypes { public const EMERGENCY_ACCESS_ACCESSED = 'emergency_access.accessed'; public const EMERGENCY_ACCESS_REVOKED = 'emergency_access.revoked'; public const EMERGENCY_ACCESS_INVALIDATED = 'emergency_access.invalidated'; + // An existing granted contact carried to the new key during a rotation. + // Kept apart from GRANTED so a carry can't be mistaken for a fresh + // designation, which is what a planted contact would be (#804 review). + public const EMERGENCY_ACCESS_CARRIED = 'emergency_access.carried'; // Export & deletion (consumed from secret-export-gdpr events when present). public const VAULT_EXPORTED = 'vault.exported'; @@ -214,7 +218,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'], @@ -237,6 +241,7 @@ final class AuditEventTypes { self::EMERGENCY_ACCESS_ACCESSED => ['grantorUserId', 'granteeUserId'], self::EMERGENCY_ACCESS_REVOKED => ['grantorUserId', 'granteeUserId'], self::EMERGENCY_ACCESS_INVALIDATED => ['grantorUserId', 'granteeUserId', 'reason'], + self::EMERGENCY_ACCESS_CARRIED => ['grantorUserId', 'granteeUserId', 'fromSuiteId', 'toSuiteId'], self::SECRET_VERSION_RESTORED => ['versionNumber'], // Rotation & expiry — ids/reasons only (§5.2). self::SECRET_EXPIRY_SET => ['expiresAt'], 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/Exception/SuiteMigrationInProgressException.php b/lib/Exception/SuiteMigrationInProgressException.php new file mode 100644 index 000000000..4a1ec60c9 --- /dev/null +++ b/lib/Exception/SuiteMigrationInProgressException.php @@ -0,0 +1,29 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Exception; + +/** + * Thrown when a suite that is part of an in-progress migration is revoked. + */ +class SuiteMigrationInProgressException extends ConflictException { +}//end class diff --git a/lib/Listener/EmergencyAccessSuiteRotationListener.php b/lib/Listener/EmergencyAccessSuiteRotationListener.php index 06af60f1d..49ee05825 100644 --- a/lib/Listener/EmergencyAccessSuiteRotationListener.php +++ b/lib/Listener/EmergencyAccessSuiteRotationListener.php @@ -4,12 +4,12 @@ * Keepiq EmergencyAccessSuiteRotationListener * * Listens for SuiteMigrationCompletedEvent (compromise recovery / key rotation) - * and invalidates the grantor's emergency-access recovery envelopes - * (add-emergency-access §3.1 / design D6). The envelopes escrow the grantor's - * OLD private key, so after a rotation they hold a stale key and MUST be - * invalidated; the grantor is then prompted (in the UI) to re-establish - * emergency access against the new key. The envelope is keyed by the old suite - * id recorded at designation. + * and invalidates the emergency-access contacts the rotation did not carry + * (add-emergency-access §3.1 / design D6). Their envelopes escrow the grantor's + * OLD private key, so they MUST be invalidated. Each is recorded with why it + * was not carried, and the UI prompts the grantor to re-establish only an + * unreachable one (see EmergencyEnvelopeInvalidationService). The envelope is + * keyed by the old suite id recorded at designation. * * @category Listener * @package OCA\Keepiq\Listener @@ -71,7 +71,17 @@ 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 every contact the browser did not carry — a grantee + // with no active certificate to seal to, one the owner did not tick, or + // one whose break-glass was in flight (the service records that as + // grantor_rotation_in_flight) — and each genuinely must be invalidated. + // Do NOT "optimise away" this apparent no-op: on a rotation with a + // contact that was not carried 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/MarksCompromisedSecrets.php b/lib/Listener/MarksCompromisedSecrets.php new file mode 100644 index 000000000..9be027661 --- /dev/null +++ b/lib/Listener/MarksCompromisedSecrets.php @@ -0,0 +1,103 @@ + + * @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\Secret; +use OCP\AppFramework\Db\DoesNotExistException; +use Throwable; + +/** + * Stamp, flag and resolve Secrets in a suite-compromise blast radius. + * + * The using class provides $secretMapper, $shareTargetMapper, $logger and + * $rotationService. + */ +trait MarksCompromisedSecrets { + /** + * Stamp a Secret possibly-compromised (once) and raise its rotation flag. + * + * The flag is idempotent (rotation-expiry-policies §3.2). A failure is + * logged and does not stop the cascade for the other Secrets. + * + * @param Secret $secret The Secret to mark + * + * @return void + * + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + */ + private function stampAndFlag(Secret $secret): void { + try { + if ($secret->getPossiblyCompromisedAt() === null) { + $secret->setPossiblyCompromisedAt(new DateTime()); + $this->secretMapper->update($secret); + } + + $this->rotationService?->flag( + secretId: $secret->getId(), + reason: 'suite_compromise' + ); + } catch (Throwable $exception) { + $this->logger->warning( + 'Keepiq: could not mark secret ' . $secret->getId() . ' possibly compromised: ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + } + }//end stampAndFlag() + + /** + * The Secret a warning about $secret should point at: for a shared copy, + * the SOURCE Secret, which its owner can open and has to rotate; otherwise + * $secret itself. + * + * Not being a shared copy, or a source that is gone, is expected and falls + * back to $secret quietly. Any other lookup failure falls back too, but is + * logged: it means the source owner is not warned. + * + * @param Secret $secret The Secret sealed under the affected suite + * + * @return Secret + * + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + */ + private function resolveTarget(Secret $secret): Secret { + try { + $row = $this->shareTargetMapper->findByRecipientSecret( + recipientSecretId: $secret->getId() + ); + return $this->secretMapper->findById($row->getSourceSecretId()); + } catch (DoesNotExistException) { + return $secret; + } catch (Throwable $exception) { + $this->logger->warning( + 'Keepiq: could not resolve the source of secret ' . $secret->getId() . ': ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + return $secret; + } + }//end resolveTarget() +}//end trait diff --git a/lib/Listener/SuiteCompromiseListener.php b/lib/Listener/SuiteCompromiseListener.php index e07cd1d87..9872e8c30 100644 --- a/lib/Listener/SuiteCompromiseListener.php +++ b/lib/Listener/SuiteCompromiseListener.php @@ -28,7 +28,6 @@ use OCA\Keepiq\Event\SuiteMigrationCompletedEvent; 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; @@ -42,6 +41,8 @@ * @spec openspec/changes/implement-user-sharing/tasks.md#8.4 */ class SuiteCompromiseListener implements IEventListener { + use MarksCompromisedSecrets; + /** * Constructor. * @@ -90,15 +91,17 @@ public function handle(Event $event): void { // Auto-raise a rotation flag per compromised secret // (rotation-expiry-policies §3.2; idempotent). - $this->rotationService?->flag( - secretId: $secret->getId(), - reason: 'suite_compromise' - ); + $this->stampAndFlag(secret: $secret); - $ownerId = $this->resolveSourceOwner( - recipientSecretId: $secret->getId(), - fallbackOwnerId: $secret->getOwnerId() - ); + $target = $this->resolveTarget(secret: $secret); + $ownerId = (string)$target->getOwnerId(); + + // The SOURCE of a shared copy is not sealed under the new + // suite, so nothing else in the migration path marks it: stamp + // and flag it here, as the revoke path does (keepiq#802). + if ($target !== $secret) { + $this->stampAndFlag(secret: $target); + } if ($ownerId === '' || isset($notified[$ownerId]) === true) { continue; @@ -111,11 +114,11 @@ public function handle(Event $event): void { 'oldSuiteId' => $event->getOldSuiteId(), 'newSuiteId' => $event->getNewSuiteId(), 'migrationId' => $event->getMigrationId(), - 'secretId' => $secret->getId(), - 'secretName' => $secret->getName(), + 'secret_id' => $target->getId(), + 'secret_name' => $target->getName(), ], objectType: 'secret', - objectId: $secret->getId(), + objectId: $target->getId(), ); $notified[$ownerId] = true; }//end foreach @@ -126,36 +129,4 @@ public function handle(Event $event): void { ); }//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/SuiteCompromiseOnRevokeListener.php b/lib/Listener/SuiteCompromiseOnRevokeListener.php new file mode 100644 index 000000000..918a4f240 --- /dev/null +++ b/lib/Listener/SuiteCompromiseOnRevokeListener.php @@ -0,0 +1,138 @@ + + * @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\Db\SecretMapper; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Event\EncryptionSuiteRevokedEvent; +use OCA\Keepiq\Service\NotificationService; +use OCA\Keepiq\Service\RotationPolicyService; +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 { + use MarksCompromisedSecrets; + + /** + * 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) { + $this->stampAndFlag(secret: $secret); + + $target = $this->resolveTarget(secret: $secret); + $ownerId = (string)$target->getOwnerId(); + + // For a shared copy the SOURCE is what its owner has to rotate, + // so it is stamped and flagged as well, not only the revoked + // user's copy (keepiq#802). + if ($target !== $secret) { + $this->stampAndFlag(secret: $target); + } + + if ($ownerId === '' || isset($notified[$ownerId]) === true) { + continue; + } + + $this->notificationService->notify( + subject: 'secret_compromised', + recipientId: $ownerId, + params: [ + 'suiteId' => $event->getSuiteId(), + 'revokedBy' => $event->getRevokedBy(), + 'secret_id' => $target->getId(), + 'secret_name' => $target->getName(), + ], + objectType: 'secret', + objectId: $target->getId(), + ); + $notified[$ownerId] = true; + }//end foreach + } catch (Throwable $exception) { + $this->logger->warning( + 'Keepiq: SuiteCompromiseOnRevokeListener failed: ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + }//end try + }//end handle() +}//end class diff --git a/lib/Listener/SuiteMigrationAbortedListener.php b/lib/Listener/SuiteMigrationAbortedListener.php new file mode 100644 index 000000000..fe424f3a9 --- /dev/null +++ b/lib/Listener/SuiteMigrationAbortedListener.php @@ -0,0 +1,94 @@ + + * @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: nothing moved onto the new suite, which an abort discards + // and a compromise termination revokes, so it must not become their + // target. unlockAndUpdateSuite(old, old) looked like this but always + // threw "must differ", so nothing was ever unlocked (#809 review). + $unlocked = $this->secretRequestService->unlockInPlace($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..d3758822c --- /dev/null +++ b/lib/Middleware/VaultKeyProofMiddleware.php @@ -0,0 +1,294 @@ + + * @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 Psr\Log\LoggerInterface; +use ReflectionMethod; +use Throwable; + +/** + * Enforce #[VaultKeyProofRequired] on the annotated controller methods. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The guard is one place that + * has to see the attribute, the request, the session, the three ways a + * subject suite is resolved (active suite, route parameter, migration end), + * the proof service, its exception and now the logger that records every + * refusal (#804 review). Splitting it would scatter the one check that must + * stay in a single middleware so it cannot be skipped. + */ +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 + * @param LoggerInterface $logger Records every refused proof + * + * @return void + */ + public function __construct( + private IRequest $request, + private IUserSession $userSession, + private EncryptionSuiteService $suiteService, + private VaultKeyProofService $proofService, + private SuiteMigrationMapper $migrationMapper, + private LoggerInterface $logger, + ) { + }//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; + } + + // A refusal is exactly what a session-only attacker produces, so it must + // leave a record rather than only a 403 (#804 review). + $this->logger->warning( + 'Keepiq: vault key proof refused on {route}: {reason}', + [ + 'app' => 'keepiq', + 'userId' => $this->userSession->getUser()?->getUID(), + 'route' => $controller::class . '::' . $methodName, + 'purpose' => $this->attributeFor(controller: $controller, methodName: $methodName)?->getPurpose(), + 'reason' => $exception->getMessage(), + ] + ); + + 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 + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-irreversible-operations-require-a-verified-key-proof + */ + private function resolveSubjectSuite(string $subject, string $userId): EncryptionSuite { + if ($subject === 'migrationNewSuite') { + // The NEW end: during a compromise recovery the old password may be + // the leaked one, so a route that must not be usable by whoever holds + // it proves the new key, held by whoever started the migration + // (#804 review; see MigrationController::reEnvelopeEmergencyContact). + $migration = $this->migrationMapper->findById((string)$this->request->getParam('id', '')); + return $this->assertOwned( + suite: $this->suiteService->getSuite($migration->getNewSuiteId()), + userId: $userId + ); + } + + 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/EmergencyAccessAuditTrail.php b/lib/Service/EmergencyAccessAuditTrail.php index a562da25b..002a7afdc 100644 --- a/lib/Service/EmergencyAccessAuditTrail.php +++ b/lib/Service/EmergencyAccessAuditTrail.php @@ -93,6 +93,43 @@ public function recordGranted( ); }//end recordGranted() + /** + * Record that a granted contact was carried to the new key in a rotation. + * + * @param string $grantorUserId The grantor (the rotating owner) + * @param string $granteeUserId The grantee + * @param string $id The emergency-contact id + * @param string $fromSuiteId The suite rotated away from + * @param string $toSuiteId The suite the new envelope escrows + * + * @return void + * + * @spec openspec/changes/add-emergency-access/specs/emergency-access/spec.md#requirement-designate-emergency-contact + */ + public function recordCarried( + string $grantorUserId, + string $granteeUserId, + string $id, + string $fromSuiteId, + string $toSuiteId, + ): void { + $this->eventDispatcher->dispatchTyped( + $this->auditEvents->forUser( + actorId: $grantorUserId, + eventType: AuditEventTypes::EMERGENCY_ACCESS_CARRIED, + objectType: self::OBJECT_TYPE, + objectId: $id, + objectName: $granteeUserId, + metadata: [ + 'grantorUserId' => $grantorUserId, + 'granteeUserId' => $granteeUserId, + 'fromSuiteId' => $fromSuiteId, + 'toSuiteId' => $toSuiteId, + ], + ) + ); + }//end recordCarried() + /** * Record that a grantor revoked an emergency contact. * diff --git a/lib/Service/EmergencyEnvelopeInvalidationService.php b/lib/Service/EmergencyEnvelopeInvalidationService.php index d02b9c3c9..39af26959 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,14 +70,22 @@ class EmergencyEnvelopeInvalidationService { */ public function __construct( private EmergencyContactMapper $mapper, + private EncryptionSuiteMapper $suiteMapper, private EmergencyAccessAuditTrail $auditTrail, ) { }//end __construct() /** - * Invalidate a grantor's recovery envelopes after their suite is ROTATED - * (compromise recovery). The envelopes hold the stale private key, so they - * are marked invalid and the grantor must re-establish emergency access. + * Invalidate the contacts a ROTATION (compromise recovery) left on the old + * suite. Their envelopes hold the stale private key, so they are marked + * invalid. + * + * A contact with a break-glass requested or approved gets `{reason}_in_flight`, + * which the view warns about: that is what a contact planted with a stolen + * session looks like. Every other contact gets `{reason}`. The server cannot + * see which contacts the owner ticked, so it does not try to tell an + * unticked contact from an unreachable one; the view offers Re-establish for + * neither, and only the recovery form prompts (#804 review). * * @param string $grantorSuiteId The rotated (old) suite ID * @param string $reason The invalidation reason tag @@ -69,13 +101,57 @@ public function invalidateForGrantorRotation(string $grantorSuiteId, string $rea continue; } - $this->invalidate(contact: $contact, reason: $reason); + $this->invalidate(contact: $contact, reason: $this->rotationReason(contact: $contact, reason: $reason)); $count++; } return $count; }//end invalidateForGrantorRotation() + /** + * Why a rotation did not carry $contact (see invalidateForGrantorRotation). + * + * @param EmergencyContact $contact The residual contact + * @param string $reason The base invalidation reason tag + * + * @return string The reason tag to record + * + * @spec openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md#requirement-envelope-invalidation-on-key-change + */ + private function rotationReason(EmergencyContact $contact, string $reason): string { + $inFlight = [EmergencyContact::STATE_REQUESTED, EmergencyContact::STATE_APPROVED]; + if (in_array($contact->getState(), $inFlight, true) === true) { + return $reason . '_in_flight'; + } + + return $reason; + }//end rotationReason() + + /** + * 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 +202,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'); + } + + // Only a `granted` contact is carried (keepiq#800). A `requested` or + // `approved` one has a break-glass in flight: carrying it would release + // the NEW private key to that grantee with the wait already served, which + // is what a contact planted with a stolen session is waiting for. An + // invalidated contact has no envelope to carry. Refused contacts stay on + // the old suite, the completion sweep invalidates them, and the grantor + // re-designates the ones they still want, with a fresh key proof. + if ($contact->getState() !== EmergencyContact::STATE_GRANTED) { + throw new ForbiddenException( + message: 'Only a granted emergency contact is carried across a key rotation' + ); + } + + $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); + + $contact->setInvalidatedReason(null); + $contact->setUpdatedAt(new DateTime()); + $updated = $this->mapper->update($contact); + + // Audited as a CARRY, not a grant, so it can't be mistaken for a fresh + // designation after an incident (#804 review). + $this->auditTrail->recordCarried( + grantorUserId: $updated->getGrantorUserId(), + granteeUserId: $updated->getGranteeUserId(), + id: $updated->getId(), + fromSuiteId: $oldSuiteId, + toSuiteId: $newSuiteId, + ); + + 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..d8926c7f1 100644 --- a/lib/Service/MigrationService.php +++ b/lib/Service/MigrationService.php @@ -25,9 +25,12 @@ 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 OCA\Keepiq\Exception\SuiteMigrationInProgressException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; use Psr\Log\LoggerInterface; @@ -222,6 +225,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. @@ -493,6 +609,97 @@ private function resolveOwnerId(string $suiteId): ?string { return $ownerId; }//end resolveOwnerId() + /** + * Refuse when the suite is either end of a migration still in progress. + * + * Revoking the old end blocks the reads the owner's browser needs to + * re-encrypt; revoking the new end strands what was already re-encrypted. + * Either way the migration and the write lock stay `in_progress` with no + * way to finish (keepiq#803). Both revoke paths call this before touching + * anything. It is a check, not a lock: a migration started in the moment + * between this call and revokeSuite() is not caught. + * + * @param string $suiteId The suite about to be revoked + * + * @return void + * + * @throws SuiteMigrationInProgressException When a migration involving the suite is in progress + * + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-a-suite-in-an-in-progress-migration-cannot-be-revoked + */ + public function assertNoMigrationInProgress(string $suiteId): void { + foreach ($this->mapper->findBySuiteId(suiteId: $suiteId) as $migration) { + if ($migration->getStatus() === 'in_progress') { + throw new SuiteMigrationInProgressException( + message: 'This suite is part of a key migration that is still in progress. ' + . 'It can be revoked once that migration is completed or aborted.' + ); + } + } + + }//end assertNoMigrationInProgress() + + /** + * The suite's in-progress migration, or null. Changes nothing. + * + * @param string $suiteId Either end of the migration + * + * @return SuiteMigration|null + * + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-a-suite-in-an-in-progress-migration-cannot-be-revoked + */ + public function findInProgressForSuite(string $suiteId): ?SuiteMigration { + foreach ($this->mapper->findBySuiteId(suiteId: $suiteId) as $migration) { + if ($migration->getStatus() === 'in_progress') { + return $migration; + } + } + + return null; + + }//end findInProgressForSuite() + + /** + * End a migration for a compromise force-revoke. + * + * The owner's abort refuses once anything is committed, and every migration + * route is owner-only. So whoever holds the session and the leaked password + * could start a recovery, commit one record and walk away, and the admin's + * containment would be blocked for good (keepiq#809 review). A compromise + * force-revoke therefore ends the migration instead: status `terminated`, + * which releases the write lock, plus the same aborted event the owner's + * abort dispatches, which unlocks the SecretRequests it locked and leaves + * them on the old suite. That suite is revoked by then, so their public + * links refuse them (SecretRequestPolicy: reason `unavailable`). The caller revokes BOTH ends first, so a failure + * before this point leaves the migration open for a retry to find. + * + * @param SuiteMigration $migration The in-progress migration + * + * @return void + * + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-a-suite-in-an-in-progress-migration-cannot-be-revoked + */ + public function terminateForCompromise(SuiteMigration $migration): void { + $migration->setStatus('terminated'); + $migration->setCompletedAt(new DateTime()); + $this->mapper->update($migration); + $this->workService->clearFailureAccounting(migration: $migration); + + $this->eventDispatcher?->dispatchTyped( + new SuiteMigrationAbortedEvent( + oldSuiteId: $migration->getOldSuiteId(), + newSuiteId: $migration->getNewSuiteId(), + migrationId: $migration->getId(), + ) + ); + + $this->logger->warning( + 'Keepiq: migration terminated by a compromise force-revoke', + ['migrationId' => $migration->getId()] + ); + + }//end terminateForCompromise() + /** * Get in-progress migration for a given owner (via their old suite). * 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/SecretRequestPolicy.php b/lib/Service/SecretRequestPolicy.php index 995f18a9f..fcdb4c11e 100644 --- a/lib/Service/SecretRequestPolicy.php +++ b/lib/Service/SecretRequestPolicy.php @@ -85,6 +85,7 @@ class SecretRequestPolicy { self::REASON_FULFILLED, self::REASON_DECLINED, self::REASON_LOCKED, + self::REASON_UNAVAILABLE, self::REASON_UNKNOWN, ]; @@ -123,6 +124,15 @@ class SecretRequestPolicy { */ public const REASON_LOCKED = 'locked'; + /** + * The request is pending, but the suite it is sealed to is no longer active + * (revoked, compromised or gone). A filled value would be encrypted to a key + * that may be in the wrong hands (#809 review). + * + * @var string + */ + public const REASON_UNAVAILABLE = 'unavailable'; + /** * A status this version does not know how to explain. * @@ -153,6 +163,7 @@ class SecretRequestPolicy { self::REASON_FULFILLED => ['message' => 'Request was already fulfilled', 'code' => 410], self::REASON_DECLINED => ['message' => 'Request was declined', 'code' => 410], self::REASON_LOCKED => ['message' => 'Request is temporarily unavailable', 'code' => 423], + self::REASON_UNAVAILABLE => ['message' => 'Request is no longer available', 'code' => 410], self::REASON_UNKNOWN => ['message' => 'Request is in an unknown state', 'code' => 500], ]; @@ -160,8 +171,9 @@ class SecretRequestPolicy { * Constructor for SecretRequestPolicy. * * @param SecretRequestMapper $mapper The request mapper + * @param EncryptionSuiteMapper $suiteMapper The suite mapper; required, because + * fill refuses a request whose suite is not active * @param SecretMapper|null $secretMapper Optional Secret mapper for owner lookups - * @param EncryptionSuiteMapper|null $suiteMapper Optional suite mapper * * @return void * @@ -169,8 +181,8 @@ class SecretRequestPolicy { */ public function __construct( private SecretRequestMapper $mapper, + private EncryptionSuiteMapper $suiteMapper, private ?SecretMapper $secretMapper = null, - private ?EncryptionSuiteMapper $suiteMapper = null, ) { }//end __construct() @@ -283,7 +295,7 @@ private function classify(SecretRequest $entity): string { return self::REASON_EXPIRED; } - return match ($entity->getStatus()) { + $reason = match ($entity->getStatus()) { SecretRequest::STATUS_LOCKED => self::REASON_LOCKED, SecretRequest::STATUS_FULFILLED => self::REASON_FULFILLED, SecretRequest::STATUS_DECLINED => self::REASON_DECLINED, @@ -291,8 +303,39 @@ private function classify(SecretRequest $entity): string { SecretRequest::STATUS_PENDING => self::REASON_OPEN, default => self::REASON_UNKNOWN, }; + + // Open only while the suite it is sealed to is still active. After a + // compromise force-revoke, with or without a migration, the requests + // are pending on a revoked suite, and the fill page would hand out its + // certificate (#809 review). + if ($reason === self::REASON_OPEN && $this->suiteIsActive(entity: $entity) === false) { + return self::REASON_UNAVAILABLE; + } + + return $reason; }//end classify() + /** + * Whether the suite a request is sealed to is still active. + * + * A suite that no longer exists counts as not active (fail closed). + * + * @param SecretRequest $entity The request + * + * @return bool + * + * @spec openspec/specs/secret-requests/spec.md#requirement-fill-in-via-link + */ + private function suiteIsActive(SecretRequest $entity): bool { + try { + $suite = $this->suiteMapper->findById((string)$entity->getEncryptionSuiteId()); + } catch (DoesNotExistException) { + return false; + } + + return $suite->getStatus() === 'active'; + }//end suiteIsActive() + /** * Re-read a request by ID and assert it is still pending. Used by the * fill flow to defend against a parallel fill that raced the token @@ -481,7 +524,6 @@ public function requireListableSecret(string $secretId, string $userId): void { * * @throws InvalidArgumentException When the application ID is blank or * it has no active suite. - * @throws RuntimeException When the suite mapper dependency is not wired. * * @spec openspec/specs/secret-requests/spec.md#requirement-create-secret-request */ @@ -490,10 +532,6 @@ public function requireApplicationSuiteId(string $applicationId): string { throw new InvalidArgumentException(message: 'applicationId is required'); } - if ($this->suiteMapper === null) { - throw new RuntimeException(message: 'EncryptionSuite mapper not wired for application requests'); - } - try { $suite = $this->suiteMapper->findActiveByOwner('application', $applicationId); // The mapper bounds this query with maxResults(1), so findEntity() diff --git a/lib/Service/SecretRequestSuiteLockService.php b/lib/Service/SecretRequestSuiteLockService.php index 843ba9b1d..b82b82762 100644 --- a/lib/Service/SecretRequestSuiteLockService.php +++ b/lib/Service/SecretRequestSuiteLockService.php @@ -79,6 +79,36 @@ public function lockByEncryptionSuiteId(string $encryptionSuiteId): int { return $count; }//end lockByEncryptionSuiteId() + /** + * Unlock the requests locked on a suite, leaving them on that suite. + * + * The counterpart of lockByEncryptionSuiteId() for a migration that moved + * nothing onto the new suite. unlockAndUpdateSuite() cannot do this: it + * refuses an old and new suite that are the same (#809 review). + * + * @param string $encryptionSuiteId The suite the requests are locked on + * + * @return int The number of requests unlocked + * + * @throws InvalidArgumentException When the suite id is empty + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-suite-migration + */ + public function unlockInPlace(string $encryptionSuiteId): int { + if ($encryptionSuiteId === '') { + throw new InvalidArgumentException(message: 'A suite ID is required'); + } + + $count = $this->mapper->unlockByEncryptionSuiteId($encryptionSuiteId); + + $this->logger->info( + 'Unlocked ' . $count . ' secret requests on suite ' . $encryptionSuiteId, + ['app' => 'keepiq'] + ); + + return $count; + }//end unlockInPlace() + /** * Re-point locked requests at a new EncryptionSuite + reopen them. * diff --git a/lib/Service/SecretService.php b/lib/Service/SecretService.php index 7ede30a58..759e9acf8 100644 --- a/lib/Service/SecretService.php +++ b/lib/Service/SecretService.php @@ -135,6 +135,8 @@ class SecretService { * @param SecretVersionService|null $versionService The version-history service (pre-update snapshots) * @param RotationPolicyService|null $rotationService The rotation service (flag cascade) * @param AuditEventFactory $auditEvents The audit-event factory + * @param FolderOwnershipGuard|null $folderOwnership Checks a secret's folder belongs to its owner (keepiq#795); + * without it every folder is refused * * @return void */ @@ -155,6 +157,7 @@ public function __construct( private ?SecretVersionService $versionService = null, private ?RotationPolicyService $rotationService = null, private AuditEventFactory $auditEvents = new AuditEventFactory(), + private ?FolderOwnershipGuard $folderOwnership = null, ) { }//end __construct() @@ -250,6 +253,9 @@ public function create(array $data, string $userId, bool $allowUnfilled = false) throw new InvalidArgumentException('A secret requires a name and a key'); } + $folderId = $this->nullableString(value: $data['folderId'] ?? null); + $this->requireFolderOwnedBy(folderId: $folderId, userId: $userId); + $suite = $this->getActiveSuiteOrBlock(userId: $userId); $typeId = $this->typeService->resolveTypeForSecret( @@ -263,7 +269,7 @@ public function create(array $data, string $userId, bool $allowUnfilled = false) $secret->setName($name); $secret->setUrl($this->nullableString(value: $data['url'] ?? null)); $secret->setTypeId($typeId); - $secret->setFolderId($this->nullableString(value: $data['folderId'] ?? null)); + $secret->setFolderId($folderId); $secret->setKey($key); $secret->setLogin($this->nullableString(value: $data['login'] ?? null)); $secret->setAdditionalFields($this->nullableString(value: $data['additionalFields'] ?? null)); @@ -325,6 +331,11 @@ public function createForApplication(array $data, string $applicationId, string throw new InvalidArgumentException('A secret requires a name and a key'); } + // The writing user files the application's secret, so the folder is + // checked against that user. + $folderId = $this->nullableString(value: $data['folderId'] ?? null); + $this->requireFolderOwnedBy(folderId: $folderId, userId: $writingUserId); + try { $suite = $this->suiteMapper->findActiveByOwner('application', $applicationId); // No MultipleObjectsReturnedException arm: findActiveByOwner() @@ -352,7 +363,7 @@ public function createForApplication(array $data, string $applicationId, string $secret->setName($name); $secret->setUrl($this->nullableString(value: $data['url'] ?? null)); $secret->setTypeId($typeId); - $secret->setFolderId($this->nullableString(value: $data['folderId'] ?? null)); + $secret->setFolderId($folderId); $secret->setKey($key); $secret->setLogin($this->nullableString(value: $data['login'] ?? null)); $secret->setAdditionalFields($this->nullableString(value: $data['additionalFields'] ?? null)); @@ -448,13 +459,20 @@ public function createByApplication( $applicationId ); + $folderId = $this->nullableString(value: $data['folderId'] ?? null); + // Keepiq#795: folders belong to users and a machine write has no user, so an + // application cannot file a secret in a folder. Clearing it stays allowed. + if ($folderId !== null) { + throw new InvalidArgumentException('An application cannot file a secret in a folder'); + } + $now = new DateTime(); $secret = new Secret(); $secret->setId(Uuid::uuid4()->toString()); $secret->setName($name); $secret->setUrl($this->nullableString(value: $data['url'] ?? null)); $secret->setTypeId($typeId); - $secret->setFolderId($this->nullableString(value: $data['folderId'] ?? null)); + $secret->setFolderId($folderId); $secret->setKey($key); $secret->setLogin($this->nullableString(value: $data['login'] ?? null)); $secret->setAdditionalFields($this->nullableString(value: $data['additionalFields'] ?? null)); @@ -542,7 +560,13 @@ public function updateByApplication(string $id, array $data, string $application } if (array_key_exists('folderId', $data) === true) { - $secret->setFolderId($this->nullableString(value: $data['folderId'])); + $folderId = $this->nullableString(value: $data['folderId']); + // Keepiq#795: folders belong to users and a machine write has no user, so an + // application cannot file a secret in a folder. Clearing it stays allowed. + if ($folderId !== null) { + throw new InvalidArgumentException('An application cannot file a secret in a folder'); + } + $secret->setFolderId($folderId); } if (array_key_exists('login', $data) === true) { @@ -801,6 +825,33 @@ public function get(string $id, string $userId): Secret { return $secret; }//end get() + /** + * Refuse a folder the given user does not own (keepiq#795). + * + * A secret may only be filed in a folder its owner owns: the folder owner's + * delete counts and purges every secret in it without an owner filter. + * Without the guard wired, every folder is refused rather than trusted. + * A null folder (no folder, or clearing it) is always allowed. + * + * @param string|null $folderId The folder the secret is filed in + * @param string $userId The user who must own that folder + * + * @return void + * + * @throws NotFoundException When the folder does not exist + * @throws ForbiddenException When the folder belongs to another user or cannot be checked + * + * @spec exclude keepiq#795 security fix, no OpenSpec requirement names this guard yet + */ + private function requireFolderOwnedBy(?string $folderId, string $userId): void { + if ($folderId === null) { + return; + } + + ($this->folderOwnership ?? throw new ForbiddenException(message: 'The folder cannot be checked')) + ->requireOwned(id: $folderId, userId: $userId); + }//end requireFolderOwnedBy() + /** * Update a secret owned by the user. * @@ -846,7 +897,12 @@ public function update(string $id, array $data, string $userId): Secret { } if (array_key_exists('folderId', $data) === true) { - $secret->setFolderId($this->nullableString(value: $data['folderId'])); + $folderId = $this->nullableString(value: $data['folderId']); + if ($folderId !== $secret->getFolderId()) { + $this->requireFolderOwnedBy(folderId: $folderId, userId: $userId); + } + + $secret->setFolderId($folderId); } if (array_key_exists('typeId', $data) === true) { 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..3d20b83cb --- /dev/null +++ b/lib/Service/VaultKeyProofService.php @@ -0,0 +1,368 @@ + + * @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\ICacheFactory; +use OCP\IConfig; +use OCP\IMemcache; +use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Stateless issuance and verification of vault-key proofs. + */ +class VaultKeyProofService { + /** + * How long a challenge is valid, in seconds. + */ + private const TTL = 300; + + /** + * Distributed cache namespace for consumed nonces (single-use proofs). + */ + public const USED_NONCE_CACHE_NS = 'keepiq_proof_nonce'; + + /** + * 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'; + public const PURPOSE_EMERGENCY_DESIGNATE = 'emergency-access-designate'; + public const PURPOSE_EMERGENCY_RE_ENVELOPE = 'emergency-access-re-envelope'; + public const PURPOSE_DELETE_ACCOUNT_DATA = 'delete-account-data'; + + /** + * 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, + self::PURPOSE_EMERGENCY_DESIGNATE, + self::PURPOSE_EMERGENCY_RE_ENVELOPE, + self::PURPOSE_DELETE_ACCOUNT_DATA, + ]; + + /** + * Whether the missing-memcache warning was already logged. + * + * @var boolean + */ + private bool $reuseWarningLogged = false; + + /** + * 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 + * @param ICacheFactory $cacheFactory Holds consumed nonces, so each proof is single-use + * @param LoggerInterface $logger Says so when single use cannot be enforced + * + * @return void + * + * @spec exclude Constructor wiring only — no domain logic. + */ + public function __construct( + private IConfig $config, + private ISecureRandom $secureRandom, + private ITimeFactory $timeFactory, + private ICacheFactory $cacheFactory, + private LoggerInterface $logger, + ) { + }//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'); + } + + // Only a proof that fully verified is consumed, so nothing can burn a + // nonce it could not have used. + $this->consume(nonce: $nonce, expiresAt: (int)$claims['e']); + }//end verify() + + /** + * Mark a nonce used for the rest of its lifetime, or refuse a reuse. + * + * Atomic add() where the cache supports it; otherwise hasKey() then set(), + * as JwtAuthService does for jti. + * + * @param string $nonce The verified challenge + * @param int $expiresAt When the challenge expires + * + * @return void + * + * @throws KeyProofRequiredException When the nonce was already used + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + */ + private function consume(string $nonce, int $expiresAt): void { + if ($this->cacheFactory->isAvailable() === false) { + $this->warnReuseUndetected(); + } + + $cache = $this->cacheFactory->createDistributed(self::USED_NONCE_CACHE_NS); + $key = hash('sha256', $nonce); + $ttl = max(1, ($expiresAt - $this->timeFactory->getTime())); + + if ($cache instanceof IMemcache) { + if ($cache->add($key, 1, $ttl) === false) { + throw new KeyProofRequiredException(message: 'Proof already used'); + } + + return; + } + + if ($cache->hasKey($key) === true) { + throw new KeyProofRequiredException(message: 'Proof already used'); + } + + $cache->set($key, 1, $ttl); + }//end consume() + + /** + * Log, once per request, that proofs cannot be made single-use here. + * + * Without a configured memcache Nextcloud hands out a NullCache, whose + * add() always succeeds, so a reused proof is not detected. The spec states + * that limit; this makes it visible to an administrator (#804 review). + * + * @return void + * + * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + */ + private function warnReuseUndetected(): void { + if ($this->reuseWarningLogged === true) { + return; + } + + $this->reuseWarningLogged = true; + $this->logger->warning( + 'Keepiq: no memcache is configured, so a reused vault-key proof is not detected. ' + . 'Configure a distributed memcache (memcache.distributed) to make proofs single-use.', + ['app' => 'keepiq'] + ); + }//end warnReuseUndetected() + + /** + * 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-auto-confirm-members/.openspec.yaml b/openspec/changes/admin-auto-confirm-members/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-auto-confirm-members/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-auto-confirm-members/design.md b/openspec/changes/admin-auto-confirm-members/design.md new file mode 100644 index 000000000..5dee5dc64 --- /dev/null +++ b/openspec/changes/admin-auto-confirm-members/design.md @@ -0,0 +1,80 @@ +# Design: automatic confirmation of new team folder members + +## Context + +Read at development `4c214a9d`. + +- `lib/Service/TeamFolderService.php:451` `handleGroupMemberJoin()` notifies the folder owner with `team_folder_join_request` when a user joins a member group. Nothing else happens. +- `lib/Service/TeamFolderService.php:496` `approveJoin()` returns the new member's certificate and the subtree secrets, owner only. Its store action `src/store/modules/teamFolder.js:202` has no caller in `src`. +- `lib/Service/TeamFolderService.php:386` `reconcile()` computes the missing (secret, user) pairs; `:423` `registerFanOutShares()` stores browser-encrypted rows. Both call `loadOwnedTeamFolder()`, so only the owner can run them. +- `src/store/modules/teamFolder.js:278` `runFanOut()` reconciles, decrypts each source secret with the session key, encrypts per recipient certificate and posts in chunks. It runs when the owner opens the dialog (`src/modals/TeamFolderDialog.vue:469` shows the pending count). +- `lib/Service/TeamFolderShareService.php:93` stores rows inside one transaction and sends `team_folder_shared` once per new recipient; `:263` `createFanOutShare()` skips rows outside the subtree, for the owner, already existing, or for a user without an active suite. +- `lib/Service/TeamFolderService.php:620` `resolveGrade()` returns a member's effective grade. `lib/Service/ShareSyncService.php:230` `assertSyncSourceUnchanged()` refuses a write based on a stale view of the source. +- `src/store/modules/session.js:97` `unlockFromBlob()` is the single place every unlock path ends. + +## Goals / Non-Goals + +**Goals:** + +- A new member receives their copies without anyone clicking, as soon as one authorised member has an unlocked vault. +- The server never decrypts, never holds a key, and never picks the value it hands out. +- An administrator decides whether the behaviour is on. + +**Non-Goals:** + +- Server-side fan-out. The server holds no usable private key (ADR-003), so it cannot produce a recipient copy. +- A per-folder override. The switch is instance-wide; a per-folder opt-out needs a column and can follow. +- Confirming users who are not covered by a membership row. Coverage stays with the owner and Nextcloud groups. + +## Decisions + +### D1: An admin policy switch, off by default + +`team_folder_auto_confirm` is a boolean app config key in the Policies area, written through `PUT /api/settings/admin` and audited like other policy changes. `getPolicy()` exposes it to the browser. + +Alternative considered: always on. Rejected: some organisations want the owner to see each new member before access lands, which is today's behaviour. + +### D2: Authorised confirmers are the owner and write-grade members + +A `write` grade already lets a member push a new value to every member (`folder-permission-grades` spec). Handing the current value to one more covered member adds no power they lack. A `read` member could hand a new colleague a wrong value that no one else sees, so `read` members are not confirmers. + +Alternative considered: any member confirms. Rejected for that reason. + +### D3: A confirmer re-encrypts their own copy, if it is current + +The owner decrypts the source secret, as `runFanOut()` does today. A `write` member decrypts their own recipient copy. The server accepts a member's row only when the member holds a live derived copy of that source secret and that copy's `updated_at` is not older than the source's `key_updated_at`, the same staleness rule `ShareSyncService` applies to writes. A stale copy is skipped and the pair stays pending for the next confirmer. + +### D4: One pending-confirmations endpoint for the confirmer + +`GET /api/v1/team-folders/pending-confirmations` returns, for the session user, each team folder where they are a confirmer and pairs are missing: the folder id, the missing pairs, each recipient's certificate, and for a member the id of their own copy per source secret. It reuses the reconcile queries without the owner check, and it excludes disabled accounts and users without an active suite. It returns nothing when the switch is off. + +### D5: The browser confirms after unlock, without a click + +After `unlockFromBlob()` succeeds and the switch is on, the session store starts `teamFolder.autoConfirm()` in the background: fetch pending, decrypt each needed copy once, encrypt per recipient, post in chunks to `POST /api/v1/team-folders/{id}/shares`. It repeats every 15 minutes while unlocked and stops on lock. It shows one quiet notice, for example "Gave 2 new members access to Finance". A failure is logged and retried on the next run; it never blocks the vault. + +Alternative considered: a service worker that runs while the tab is closed. Rejected: the non-extractable key lives in the page's memory and is cleared on close (ADR-003); a worker would need the key outside that boundary. + +### D6: Everyone learns what happened + +The new member receives the existing `team_folder_shared` notification from `registerFanOutShares()`. The owner receives a new `team_folder_member_confirmed` notification naming the confirmer and the new member, routed through the existing `notify_group_shares` setting. The audit event for the fan-out records the confirmer as actor. + +## Security and zero-knowledge + +- The server never sees plaintext. A confirmer's browser decrypts with its own non-extractable key and posts only ciphertext encrypted for the recipient. +- Stored encrypted: the new recipient copies (RSA ciphertext, as today). Stored plain: the policy switch and the share rows' identifiers. +- The certificate trust is unchanged: the confirmer encrypts to the certificate the server returns for a covered user, which is exactly what the owner's manual fan-out does today. +- A confirmer cannot add a user: the server accepts a row only for a pair that is missing and covered by a membership row. + +## Risks / Trade-offs + +- If no confirmer unlocks, the new member keeps waiting. The team folder dialog shows the pending count with "Waiting for a member with write access to open Keepiq". +- A run in several confirmers' browsers at once can race. Row creation is idempotent (`createFanOutShare()` skips an existing share), so the loser creates nothing. +- Decrypting many secrets in the background costs CPU after unlock. Runs are chunked and yield between chunks, as `runFanOut()` already does. + +## Seed data + +None. PHPUnit tests build folders, grades and copies with mocks; the Playwright test adds a member to a folder where a `write` member unlocks. + +## Migration + +None. No table or column; one app config key. `` in `appinfo/info.xml` does not need a bump for schema reasons. diff --git a/openspec/changes/admin-auto-confirm-members/proposal.md b/openspec/changes/admin-auto-confirm-members/proposal.md new file mode 100644 index 000000000..0781e344e --- /dev/null +++ b/openspec/changes/admin-auto-confirm-members/proposal.md @@ -0,0 +1,52 @@ +--- +kind: code +--- + +# Automatic confirmation of new team folder members + +## Why + +A new team folder member gets no secrets until the folder owner opens the team folder dialog and runs the key fan-out. When the owner is on leave, the new colleague waits. This change confirms new members automatically, without breaking zero knowledge. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-25 | New members are confirmed automatically, without an administrator handing over access by hand | A new team folder member gets access only when the owner's browser runs the key fan-out; there is no automatic confirmation. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +- changelog: https://github.com/bitwarden/clients/releases/tag/web-v2026.3.1 + +### Competitors rated yes + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Core/AdminConsole/Enums/PolicyType.cs:27 AutomaticUserConfirmation ('Automatically confirm invited users'); bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts:33 AutoConfirmPolicy ..." +- HashiCorp Vault: "hashicorp/vault@v2.1.1 vault/identity_store_group_aliases.go:21 group-alias maps an external group (OIDC, LDAP) to a Vault group and its policies. Note: Vault encrypts server-side, so there is no key handover to confirm; a new member gets the policies of their mapped groups at first login. ..." +- Nextcloud Passwords: "marius-wieschollek/passwords@2026.9.0 src/lib/Controller/Api/ShareApiController.php:181 canShareWithUser() accepts any Nextcloud user; src/js/Actions/Share/CreateShareAction.js:90 #disableCse() moves shared items to server-side encryption, so no key handover is needed ..." + +## What Changes + +- An admin policy switch "Automatically confirm new team folder members" (`team_folder_auto_confirm`, off by default) in the Policies area of the Keepiq admin settings. +- With the switch on, the key fan-out for a new member runs in the unlocked browser of any authorised confirmer: the folder owner, or a member whose effective grade on the folder is `write`. It runs on unlock and every 15 minutes while the vault stays unlocked, with no click. +- A new endpoint `GET /api/v1/team-folders/pending-confirmations` tells a confirmer which folders have members waiting, with their certificates. +- `POST /api/v1/team-folders/{id}/shares` accepts rows from a `write`-grade confirmer who re-encrypts their own current copy, not only from the owner. +- The new member gets the existing "team folder shared" notification; the owner gets a notice naming who confirmed whom. +- With the switch off, nothing changes: the owner runs the fan-out as today. + +## Capabilities + +### New Capabilities + +- `team-folder-auto-confirm`: new team folder members receive their key copies automatically from any authorised member's unlocked browser, under an admin policy switch. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: a pending-confirmations query next to `TeamFolderService::reconcile()`; `registerFanOutShares()` accepts a `write`-grade confirmer with a copy freshness check; the policy key joins `AdminSettingsService` and `getPolicy()`; a new notification subject for the owner. +- **Frontend**: an `autoConfirm()` action in `src/store/modules/teamFolder.js` started after unlock in `src/store/modules/session.js`; a switch in the admin Policies area; the team folder dialog shows who confirmed and what still waits. +- **Database**: none. +- **Security**: the server still never decrypts. Only members already trusted to write a value for the whole team may hand a copy to a new member, and only from a copy as new as the source. +- **Cross-app**: none. diff --git a/openspec/changes/admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md b/openspec/changes/admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md new file mode 100644 index 000000000..0e1d8dcd8 --- /dev/null +++ b/openspec/changes/admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Administrator switches automatic member confirmation on + +The system MUST offer an admin policy switch `team_folder_auto_confirm`, off by default, in the Policies area of the Keepiq admin settings. Only an administrator MUST be able to change it. Every change MUST be audited. `GET /api/settings/policy` MUST expose its value to the browser. With the switch off, new members MUST receive copies only through the owner's fan-out, as before. + +#### Scenario: Administrator turns on automatic confirmation + +- **GIVEN** an administrator on the Keepiq admin settings page +- **WHEN** they switch on "Automatically confirm new team folder members" and save +- **THEN** `GET /api/settings/policy` MUST return `team_folder_auto_confirm` as true +- **AND** one policy audit event MUST be recorded + +### Requirement: Pending confirmations are served to authorised confirmers only + +`GET /api/v1/team-folders/pending-confirmations` MUST return, for the session user, each team folder where that user is the owner or has an effective `write` grade and where covered members still miss copies. Each entry MUST carry the missing pairs, the recipients' certificates and, for a member, the ids of their own copies. It MUST exclude disabled accounts and users without an active suite. It MUST return an empty list when the switch is off or the user is a `read` member or a non-member. + +#### Scenario: Write member sees a waiting colleague + +- **GIVEN** the switch is on, `hank` has a `write` grade on team folder `Ops`, and `kim` just joined group `ops-team`, a member of `Ops` +- **WHEN** `hank`'s browser calls `GET /api/v1/team-folders/pending-confirmations` +- **THEN** the response MUST list `Ops` with the missing pairs for `kim` and `kim`'s certificate + +#### Scenario: Read member sees nothing + +- **GIVEN** the switch is on and `jack` has a `read` grade on team folder `Ops` with a waiting member +- **WHEN** `jack`'s browser calls `GET /api/v1/team-folders/pending-confirmations` +- **THEN** the response MUST be an empty list + +### Requirement: An unlocked confirmer's browser confirms without a click + +When the switch is on, the browser of an authorised confirmer MUST, after the vault unlocks and every 15 minutes while it stays unlocked, fetch pending confirmations, decrypt the needed secret (the owner's source, or the member's own copy) with the session key, encrypt it under each recipient's certificate, and post the rows to `POST /api/v1/team-folders/{id}/shares`. No request MUST carry plaintext. The run MUST stop when the vault locks. + +#### Scenario: New member gets access without the owner + +- **GIVEN** the switch is on, owner `iris` of team folder `Ops` is away, and `kim` joined a member group of `Ops` +- **WHEN** `write` member `hank` unlocks his vault on the lock screen at `/lock` +- **THEN** `kim` MUST receive a copy of every secret in `Ops` without any click +- **AND** `kim` MUST receive the "team folder shared" notification +- **AND** `iris` MUST receive a notification that `hank` confirmed `kim` + +### Requirement: The server accepts a confirmer's row only when it is safe + +`POST /api/v1/team-folders/{id}/shares` MUST accept a row from a non-owner only when the caller's effective grade on the folder is `write`, the target user is covered by a membership row and still misses that copy, the source secret is inside the folder subtree, and the caller's own copy of that source is not older than the source's last key change. Otherwise the row MUST be skipped and nothing MUST be stored for it. The server MUST NOT decrypt any submitted blob. + +#### Scenario: Stale copy is refused + +- **GIVEN** `hank`'s copy of secret `db-root` in `Ops` is older than the last key change of the source +- **WHEN** `hank`'s browser posts a row for `db-root` to new member `kim` +- **THEN** no copy for `kim` MUST be stored from that row +- **AND** the pair MUST stay pending for the next confirmer + +#### Scenario: Uncovered user is refused + +- **GIVEN** user `lee` is not covered by any membership row of `Ops` +- **WHEN** a `write` member of `Ops` posts a row targeting `lee` +- **THEN** no copy for `lee` MUST be stored diff --git a/openspec/changes/admin-auto-confirm-members/tasks.md b/openspec/changes/admin-auto-confirm-members/tasks.md new file mode 100644 index 000000000..dab77c006 --- /dev/null +++ b/openspec/changes/admin-auto-confirm-members/tasks.md @@ -0,0 +1,27 @@ +## 1. Policy switch + +- [ ] 1.1 Add `team_folder_auto_confirm` to the admin settings validation, the policy audit and `getPolicy()`. Verify with a PHPUnit test in `tests/Unit/Service/AdminSettingsServiceTest.php`. +- [ ] 1.2 Add the switch to the Policies area of the admin settings. Verify with a vitest in `tests/components/`. + +## 2. Server + +- [ ] 2.1 Add `TeamFolderService::pendingConfirmations($userId)` and `GET /api/v1/team-folders/pending-confirmations`, returning folders where the user is owner or `write` member with missing pairs, certificates and own-copy ids. Verify with PHPUnit tests for owner, `write` member, `read` member, switch off and a disabled recipient. +- [ ] 2.2 Let `registerFanOutShares()` accept a `write`-grade confirmer: check the grade with `resolveGrade()`, the pair is missing and covered, and the confirmer's copy is not older than the source's `key_updated_at`. Verify with PHPUnit tests for each refusal and for an accepted row. +- [ ] 2.3 Send `team_folder_member_confirmed` to the owner and record the confirmer as audit actor. Verify with a PHPUnit test on the notification and audit metadata. +- [ ] 2.4 Run the no-admin-idor and route-auth hydra gates on the new and changed endpoints. Verify by a green gate run. + +## 3. Browser + +- [ ] 3.1 Add `autoConfirm()` to `src/store/modules/teamFolder.js`: fetch pending, decrypt the owner source or the member's own copy once, encrypt per recipient, post in chunks. Verify with a vitest that mocks the crypto and asserts no plaintext leaves the store. +- [ ] 3.2 Start `autoConfirm()` after `unlockFromBlob()` when the switch is on, repeat every 15 minutes, stop on lock. Verify with a vitest using fake timers. +- [ ] 3.3 Show who confirmed and the waiting state in `TeamFolderDialog.vue`. Verify with a vitest. +- [ ] 3.4 Cover the flow end to end. Verify with a Playwright test in `tests/e2e/workflows/`: the switch is on, a user joins a member group, a `write` member unlocks, and the new member can open a folder secret without the owner acting. + +## Acceptance criteria + +- With the switch on, a new member of a team folder can read its secrets after any `write` member or the owner unlocks, with no click by anyone. +- With the switch off, behaviour is unchanged. +- A `read` member's browser never receives pending confirmations and cannot register a row for another user. +- A confirmer whose copy is older than the source cannot hand it out. +- No request from the auto-confirm flow carries plaintext; the server stores only recipient ciphertext. +- The owner is notified of every automatic confirmation. diff --git a/openspec/changes/admin-member-overview-and-offboarding/.openspec.yaml b/openspec/changes/admin-member-overview-and-offboarding/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-member-overview-and-offboarding/design.md b/openspec/changes/admin-member-overview-and-offboarding/design.md new file mode 100644 index 000000000..c939b8703 --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/design.md @@ -0,0 +1,94 @@ +# Design: admin member overview and complete offboarding + +## Context + +Read at development `4c214a9d`. + +Offboarding today: + +- `lib/Controller/TeamFolderController.php:236` `offboard()` is `#[NoAdminRequired]` and hands the session user to the service, which asserts the caller is an instance admin or in `vault_admin` (`lib/Service/TeamFolderOffboardingService.php:139`). +- `lib/Service/TeamFolderOffboardingService.php:83` `offboard()` runs two steps: `revokeTeamSharesForUser()` at line 95, then the owned-secret transfer at line 98. It audits at line 112 and returns `revoked`, `transferred`, `skipped`. +- `lib/Service/TeamFolderShareService.php:232` revokes only share rows that carry a `team_folder_id`. The membership rows are untouched. +- `lib/Db/TeamFolderMemberMapper.php:132` `findUserMemberships()` finds the direct `user` rows of a user; `:115` `findGroupMemberships()` finds group rows. +- `lib/Service/TeamFolderMembershipResolver.php:149` `effectiveUsers()` expands a folder's rows to users; `:174` `eligibleRecipients()` keeps every user with an active suite and ignores whether the Nextcloud account is enabled; `:202` `membershipRowsForUser()` returns direct plus group rows covering a user. +- `lib/Service/TeamFolderService.php:386` `reconcile()` computes missing pairs for every effective user. So a leaver whose direct row survives is re-shared the next time the owner runs the fan-out. This is the defect the matrix records at `TeamFolderOffboardingService.php:95`. +- `lib/Event/Audit/AuditEventTypes.php:266` whitelists `leavingUserId`, `successorUserId`, `revokedCount`, `transferredCount` for `TEAM_FOLDER_OFFBOARDED`. +- `src/components/settings/OffboardingSection.vue:35` to `:49` asks for both user ids as free text. + +Vault visibility today: + +- `lib/Service/ComplianceReportService.php:107` counts distinct owners of active user suites. `src/components/settings/ComplianceSection.vue:44` prints that count. +- `src/components/settings/AdminSuiteSection.vue:24` asks for a suite id as free text; no screen lists suites. +- `lib/Controller/EncryptionSuiteController.php:89` `index()` lists only the caller's own suites. +- `lib/Db/EncryptionSuiteMapper.php:160` `findActiveByOwners()` already resolves the active suite for a batch of owners in one query. +- Issue #37 (open) asks for a list of users with an active vault for the share picker. + +## Goals / Non-Goals + +**Goals:** + +- Offboarding removes every direct team folder membership of the leaver in the same action. +- The administrator learns which groups still cover the leaver, and a disabled account is never re-shared. +- An administrator sees, per user, whether a vault is set up, and acts on a row without typing ids. + +**Non-Goals:** + +- Removing the leaver from a Nextcloud group. Group membership belongs to Nextcloud's user management. +- Transferring ownership of team folders the leaver owns. The existing transfer covers owned secrets; folder ownership is unchanged. +- A share picker list for non-admin users (issue #37). That list reveals who uses the vault to every user and needs its own privacy decision. +- Invitations or a "pending" state. Keepiq has no invitation flow; a user without a suite shows as `none`. + +## Decisions + +### D1: Remove direct membership rows as offboarding step three + +After the transfer, the service loads `findUserMemberships(leaver)` and deletes each row, across every team folder. The count is returned as `membershipsRemoved` and written to the audit event. + +Step three runs after the transfer so a transfer failure leaves the memberships intact for a re-run. The share revocation already ran in step one, so the order does not widen access in any window. + +Alternative considered: call `TeamFolderService::removeMember()` per row. Rejected: that method asserts the caller owns the folder (`TeamFolderService.php:313`) and would revoke shares a second time. The offboarding service already holds the admin authority and has revoked every derived share. + +### D2: Report group coverage instead of deleting group rows + +A group row covers every member of the group. Deleting it would cut access for colleagues. The service reads the leaver's group rows through `membershipRowsForUser()` and returns them as `stillCoveredByGroups` (team folder id plus group id). The UI shows them as a warning with the advice to remove the leaver from the group or disable the account. + +Alternative considered: a per-folder exclusion list for offboarded users. Rejected: a second access list next to Nextcloud groups drifts from them, and it needs a new table. + +### D3: The fan-out skips disabled accounts + +`eligibleRecipients()` skips a user whose Nextcloud account is disabled (`IUserManager::get()` then `IUser::isEnabled()`). Disabling the account is the standard Nextcloud offboarding step, so a leaver still in a member group gets no new copy from `reconcile()`. + +Alternative considered: skip users without an active suite only (today's rule). Rejected: offboarding does not revoke the suite, so the leaver still qualifies. + +### D4: Member overview as a paged admin endpoint + +`GET /api/v1/admin/members?status=&search=&limit=&offset=` is guarded by `#[AuthorizedAdminSetting(AdminSettings::class)]`. `MemberOverviewService` pages Nextcloud users through `IUserManager::search()`, then resolves per page: active suites through `findActiveByOwners()`, the newest non-active suite status, secret counts with one grouped count on `keepiq_secrets`, direct membership counts, and whether a row exists in `keepiq_emergency_contacts` for the user as grantor. Each row returns `userId`, `displayName`, `enabled`, `vaultStatus`, `activeSuiteId`, `suiteCreatedAt`, `secretCount`, `teamFolderMemberships`, `hasEmergencyContact`. + +The path sits under `/api/v1/admin/` so the `admin-public-api` change can document it in the public admin API without a rename. + +Alternative considered: extend the compliance metrics with a user list. Rejected: compliance snapshots are immutable evidence and aggregate only (`compliance-reporting` spec, "Org-level metadata-only compliance report"); a per-user list does not belong in a snapshot. + +### D5: One admin section drives the existing actions + +`MemberOverviewSection.vue` (`CnSettingsSection` plus `CnDataTable`) lists the rows with an `NcSelect` status filter (with `inputLabel`) and a search field. The row action "Offboard" writes the user id into a small shared Pinia store that `OffboardingSection.vue` reads; "Revoke suite" does the same for `AdminSuiteSection.vue` with `activeSuiteId`. The offboarding user fields become user pickers fed by the same endpoint. + +## Security and zero-knowledge + +- The server never holds plaintext here. The member endpoint returns identifiers, counts, statuses and dates. It never returns a certificate, a private key blob or any ciphertext. +- Stored encrypted versus plain: nothing new is stored. Deleting membership rows removes plain identifiers (`team_folder_id`, `member_type`, `member_id`, `grade`). +- Offboarding stays admin only (`TeamFolderOffboardingService.php:139`). The new endpoint is admin only through the Nextcloud middleware before the controller runs. +- Removing rows narrows access. A leaver who already read a secret still knows it; the offboarding summary keeps pointing at rotation, as `admin-suite-revocation` does for revoked suites. + +## Risks / Trade-offs + +- Listing every Nextcloud user on a large instance is slow. The endpoint pages (default 50, maximum 200) and resolves suites per page in one query. +- The list tells an administrator who uses the vault. That is the point of the row, and the data was already derivable from the database. It stays admin only. +- A leaver in a member group stays covered until an administrator acts on the warning or disables the account. The summary names the groups so this is never silent. + +## Seed data + +No new fixture. On the dev instance the seeded `admin` vault (`lib/Repair/SeedDevelopmentData.php:41`) shows as `active`, and every other Nextcloud user shows as `none`. PHPUnit tests build their own users and suites with mocks. + +## Migration + +None. No new table or column; the change deletes rows from the existing `keepiq_team_folder_members` table and adds a route. `` in `appinfo/info.xml` does not need a bump for schema reasons. diff --git a/openspec/changes/admin-member-overview-and-offboarding/proposal.md b/openspec/changes/admin-member-overview-and-offboarding/proposal.md new file mode 100644 index 000000000..81c43b85e --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/proposal.md @@ -0,0 +1,64 @@ +--- +kind: code +--- + +# Admin member overview and complete offboarding + +## Why + +An administrator cannot see which users have set up a vault, and offboarding a leaver leaves their own team folder member rows behind. This change specifies the missing half of two partial rows. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-04 | Remove a leaving user from every team folder in one step | One action revokes every team-folder-derived share and hands owned team secrets to a successor. It does not delete the user's team-folder member rows, so a direct user membership survives. | +| admin-12 | See which users have set up a vault | Admins see how many users have an active vault, not which ones. The encryption-suite admin section needs a suite id typed in by hand. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden (admin-04): "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/OrganizationUsersController.cs:579 DELETE organizations/{orgId}/users/{id}, :605 POST remove (bulk), :669 revoke Note: Removing or revoking a member drops every collection and group access in one step." +- 1Password (admin-04): "https://support.1password.com/offboarding/ : suspend or remove an offboarded team member, removing all vault access" +- Passbolt (admin-04): "passbolt/passbolt_api@v5.16.0 src/Model/Table/UsersTable.php:458 softDelete: :522 GroupsUsers deleteAll for the user, :523 Permissions deleteAll for the user, folder relations removed; ... Note: Deleting a leaving user removes every permission, folder relation and group membership in one action, after sole-owned items are transferred ..." +- Keeper (admin-04): "https://docs.keeper.io/enterprise-guide/user-management-and-lifecycle : Delete User removes the user 'from all Roles, Nodes and Teams'; Lock Account or SCIM/AD Bridge suspension blocks access while Account Transfer keeps the records" +- HashiCorp Vault (admin-04): "hashicorp/vault@v2.1.1 ui/app/models/identity/entity.js:15 entity fields name, disabled, policies, metadata (disable or delete in Access > Entities); vault/identity_store_util.go:3164 external groups dropped on next login ..." +- Bitwarden (admin-12): "bitwarden/server@v2026.9.1 src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs:15 Invited, :19 Accepted, :24 Confirmed, :30 Staged; bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizations/members/ members list with status filter ..." +- 1Password (admin-12): "https://support.1password.com/add-remove-team-members/ : People list shows invited, pending confirmation and active members" +- Passbolt (admin-12): "passbolt/passbolt_styleguide@v5.16.0 src/react-extension/components/User/DisplayUsers/DisplayUsers.js:470 isRowInactive greys out users who have not completed setup; ... Note: The users workspace shows which invited users have not activated their account yet ..." +- Keeper (admin-12): "https://docs.keeper.io/enterprise-guide/user-management-and-lifecycle : user status Invited ('has not completed their account setup yet'), Active, Locked" + +### Missing halves + +- admin-04 is partial. Built: `TeamFolderOffboardingService::offboard()` revokes every team-folder-derived share and transfers owned team secrets to a successor. Missing: offboarding also removes the leaver's own team folder member rows. +- admin-12 is partial. Built: the adoption count in the compliance section. Missing: a list of which users have set up a vault. + +## What Changes + +- Offboarding gains a third step: after revoking derived shares and transferring owned team secrets, it deletes every direct `user` membership row of the leaver (`keepiq_team_folder_members`, `member_type = user`). +- A group membership row is never deleted, because it covers other people. The offboarding result names every team folder group that still covers the leaver, so the administrator can remove them from that Nextcloud group. +- The team folder fan-out skips a recipient whose Nextcloud account is disabled. A disabled leaver who still sits in a member group is never re-shared by a later reconcile. +- The offboarding audit event records the number of removed membership rows and the covering groups. +- A new admin endpoint `GET /api/v1/admin/members` lists every Nextcloud user with their vault status (`none`, `active`, `revoked`, `compromised`), active suite id, secret count, team folder membership count and whether an emergency contact is set. Metadata only. +- A new admin settings section "Members" shows that list with a status filter and search. Each row offers "Offboard" and "Revoke suite", which prefill the existing offboarding and encryption suite sections. The suite id no longer has to be typed by hand. + +## Capabilities + +### New Capabilities + +- `admin-member-overview`: an administrator lists which users have set up a vault, filters by vault status, and starts offboarding or suite revocation from the list. + +### Modified Capabilities + +- `team-folder-sharing`: offboarding removes the leaver's direct team folder memberships, reports remaining group coverage, and the fan-out never re-shares to a disabled account. + +## Impact + +- **Backend**: `TeamFolderOffboardingService` gains the membership-removal step; `TeamFolderMembershipResolver::eligibleRecipients()` skips disabled accounts; a new `MemberOverviewService` and `MemberOverviewController` serve `GET /api/v1/admin/members`; the `TEAM_FOLDER_OFFBOARDED` audit whitelist gains two keys. +- **Frontend**: a new `MemberOverviewSection.vue` in the admin settings; `OffboardingSection.vue` and `AdminSuiteSection.vue` accept a prefilled user or suite from the list. +- **Database**: none. Rows are deleted from the existing `keepiq_team_folder_members` table; no new column or table. +- **Security**: the list endpoint is admin only and returns metadata only, never a certificate, private key blob or ciphertext. Removing membership rows narrows access; it never widens it. +- **Cross-app**: none. Issue #37 asks for a list of vault users for the share picker; this change serves the administrator list only, and the share picker variant stays with #37. diff --git a/openspec/changes/admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md b/openspec/changes/admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md new file mode 100644 index 000000000..8843f350f --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Administrator lists vault status per user + +The system MUST let an administrator list every Nextcloud user with their vault status through `GET /api/v1/admin/members`. Each row MUST carry the user id, display name, whether the account is enabled, the vault status (`none`, `active`, `revoked` or `compromised`), the active suite id when there is one, the secret count, the direct team folder membership count and whether an emergency contact is set. The endpoint MUST be guarded by `#[AuthorizedAdminSetting(AdminSettings::class)]` and MUST support paging, a status filter and a search on user id or display name. + +#### Scenario: Administrator sees who has not set up a vault + +- **GIVEN** user `alice` has an active encryption suite and user `bob` has never unlocked keepiq +- **WHEN** an administrator calls `GET /api/v1/admin/members?status=none` +- **THEN** the response MUST list `bob` with `vaultStatus` `none` and no `activeSuiteId` +- **AND** the response MUST NOT list `alice` + +#### Scenario: Administrator reads the active suite id + +- **GIVEN** user `alice` has an active encryption suite +- **WHEN** an administrator calls `GET /api/v1/admin/members?search=alice` +- **THEN** the row for `alice` MUST carry `vaultStatus` `active` and her active suite id + +#### Scenario: A non-administrator is refused + +- **GIVEN** an authenticated user who is not an administrator and holds no delegation for the keepiq admin settings +- **WHEN** they call `GET /api/v1/admin/members` +- **THEN** Nextcloud MUST refuse the request before the controller runs + +### Requirement: Member overview returns metadata only + +The member overview MUST return identifiers, statuses, counts and dates only. It MUST NOT return a certificate, a private key blob, a secret name or any ciphertext. + +#### Scenario: No key material in the list + +- **GIVEN** a page of users with active suites and secrets +- **WHEN** an administrator calls `GET /api/v1/admin/members` +- **THEN** no row MUST contain a `certificate`, `privateKey`, `key`, `login` or `additionalFields` field + +### Requirement: Administrator acts on a member row + +The admin settings MUST show the member overview in a "Members" section with a status filter and a search field. Each row MUST offer "Offboard", which prefills the leaving user in the team offboarding section, and "Revoke suite", which prefills the suite id in the encryption suites section, when the user has an active suite. + +#### Scenario: Revoke a suite without typing its id + +- **GIVEN** an administrator on the Keepiq admin settings page and user `alice` with an active suite +- **WHEN** they choose "Revoke suite" on the `alice` row of the "Members" section +- **THEN** the "Encryption suites" section MUST show `alice`'s active suite id in its suite id field + +#### Scenario: Start offboarding from the list + +- **GIVEN** an administrator on the Keepiq admin settings page +- **WHEN** they choose "Offboard" on the `bob` row of the "Members" section +- **THEN** the "Team offboarding" section MUST show `bob` as the leaving user diff --git a/openspec/changes/admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md b/openspec/changes/admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md new file mode 100644 index 000000000..65145f007 --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Offboarding removes the leaver's direct team folder memberships + +The admin offboarding action (`POST /api/v1/team-folders/offboard`) MUST, after revoking derived shares and transferring owned team secrets, delete every direct `user` membership row of the leaving user in every team folder. It MUST NOT delete a group membership row. The response MUST report the number of removed rows as `membershipsRemoved` and MUST list each group row that still covers the leaver as `stillCoveredByGroups`. The `TEAM_FOLDER_OFFBOARDED` audit event MUST carry the removed row count and the covering group ids, and no key material. + +#### Scenario: Direct membership rows are removed + +- **GIVEN** leaving user `carol` is a direct member of team folders `Finance` and `Ops`, and successor `dave` +- **WHEN** an administrator runs the offboarding action for `carol` with successor `dave` +- **THEN** no `user` membership row for `carol` MUST remain in `keepiq_team_folder_members` +- **AND** the response MUST report `membershipsRemoved` as 2 + +#### Scenario: Group coverage is reported, not deleted + +- **GIVEN** leaving user `carol` is also covered by group `finance-team`, which is a member of team folder `Finance` +- **WHEN** an administrator runs the offboarding action for `carol` +- **THEN** the `finance-team` membership row MUST remain +- **AND** the response MUST list `Finance` with group `finance-team` under `stillCoveredByGroups` +- **AND** the "Team offboarding" section MUST show that group as a warning + +### Requirement: The fan-out never re-shares to a disabled account + +The team folder reconcile (`GET /api/v1/team-folders/{id}/reconcile`) MUST exclude every user whose Nextcloud account is disabled from the recipients and from the missing pairs, even when a membership row still covers that user. + +#### Scenario: Disabled leaver in a member group gets no new copy + +- **GIVEN** user `carol` is disabled in Nextcloud, still has an active suite, and is covered by group `finance-team` on team folder `Finance` +- **WHEN** the owner of `Finance` opens the team folder dialog and the reconcile runs +- **THEN** the missing pairs MUST NOT contain `carol` +- **AND** the fan-out MUST create no share for `carol` diff --git a/openspec/changes/admin-member-overview-and-offboarding/tasks.md b/openspec/changes/admin-member-overview-and-offboarding/tasks.md new file mode 100644 index 000000000..711aaf1e0 --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/tasks.md @@ -0,0 +1,29 @@ +## 1. Offboarding removes memberships + +- [ ] 1.1 Add step three to `TeamFolderOffboardingService::offboard()`: delete every row from `findUserMemberships($leavingUserId)` after the transfer and return `membershipsRemoved`. Verify with a PHPUnit test in `tests/Unit/Service/TeamFolderOffboardingServiceTest.php` that asserts the rows are deleted and group rows are kept. +- [ ] 1.2 Return `stillCoveredByGroups` (team folder id and group id per remaining group row from `membershipRowsForUser()`). Verify with a PHPUnit test for a leaver covered by one direct row and one group row. +- [ ] 1.3 Widen the `TEAM_FOLDER_OFFBOARDED` whitelist in `lib/Event/Audit/AuditEventTypes.php` with `membershipsRemovedCount` and `coveringGroupIds`, and emit them from `TeamFolderAuditor::offboarded()`. Verify with a PHPUnit test on the dispatched metadata. +- [ ] 1.4 Make `TeamFolderMembershipResolver::eligibleRecipients()` skip disabled Nextcloud accounts. Verify with a PHPUnit test where a disabled user with an active suite is absent from `reconcile()` missing pairs. +- [ ] 1.5 Show `membershipsRemoved` and the covering groups in the `OffboardingSection.vue` summary. Verify with a vitest for the summary text in `tests/components/`. + +## 2. Member overview endpoint + +- [ ] 2.1 Add `MemberOverviewService` that pages users through `IUserManager::search()` and resolves suite status, secret count, membership count and emergency contact per page. Verify with a PHPUnit test that one page issues one suite query through `findActiveByOwners()`. +- [ ] 2.2 Add `MemberOverviewController::index()` with `#[AuthorizedAdminSetting(AdminSettings::class)]` and register `GET /api/v1/admin/members` in `appinfo/routes.php` before the SPA catch-all. Verify with a PHPUnit test for status and search filters and the route-auth and route-reachability hydra gates. +- [ ] 2.3 Assert that no row carries `certificate`, `privateKey` or any ciphertext field. Verify with a PHPUnit test over the serialized rows. + +## 3. Admin UI + +- [ ] 3.1 Add `MemberOverviewSection.vue` (`CnSettingsSection`, `CnDataTable`, `NcSelect` status filter with `inputLabel`, search) and mount it in `src/views/settings/Settings.vue`. Verify with a vitest in `tests/components/` for filter and paging. +- [ ] 3.2 Add the row actions "Offboard" and "Revoke suite" that prefill `OffboardingSection.vue` and `AdminSuiteSection.vue` through a shared store. Verify with a vitest that the prefilled values reach both sections. +- [ ] 3.3 Replace the two free-text user id fields in `OffboardingSection.vue` with user pickers fed by the member endpoint. Verify with a vitest and the nc-input-labels hydra gate. +- [ ] 3.4 Cover the flow end to end. Verify with a Playwright test in `tests/e2e/workflows/` where an administrator filters on `none`, then offboards a user from the list and sees the removed membership count. + +## Acceptance criteria + +- Offboarding a leaver with a direct team folder membership leaves no `user` row for them in `keepiq_team_folder_members`. +- Group rows that cover the leaver stay in place and are named in the offboarding result. +- A disabled Nextcloud account never appears in the missing pairs of a team folder reconcile. +- `GET /api/v1/admin/members` returns each user's vault status and active suite id to an administrator, and refuses a non-administrator. +- No response of the member endpoint contains a certificate, private key blob or ciphertext. +- An administrator can start offboarding and suite revocation from a list row without typing an id. diff --git a/openspec/changes/admin-public-api/.openspec.yaml b/openspec/changes/admin-public-api/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-public-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-public-api/design.md b/openspec/changes/admin-public-api/design.md new file mode 100644 index 000000000..96192d602 --- /dev/null +++ b/openspec/changes/admin-public-api/design.md @@ -0,0 +1,90 @@ +# Design: public admin API + +## Context + +Read at development `4c214a9d`. + +- `appinfo/routes.php:27` and `:28` serve the admin settings at `/api/settings/admin`, outside the `/api/v1/` prefix; the application admin routes sit at `:271` to `:286` (`/api/v1/applications*`); audit at `:377` to `:379`; compliance at `:195` to `:199`; SIEM sinks at `:203` to `:207`; suites at `:35` to `:43`. +- The only documented, versioned contract is the machine API under `/api/v1/app/*` (`appinfo/routes.php:295` to `:321`), with a discovery document and a rule that breaking changes ship as a new version (`openspec/specs/secret-store-api/spec.md`, "Machine API Discovery Document"). +- `lib/Controller/AuditController.php:180` `index()` and the settings methods are guarded by `#[AuthorizedAdminSetting(AdminSettings::class)]`. Other admin checks are inline `isAdmin()` calls (see change `admin-scoped-roles`). +- Nextcloud's `Request::passesCSRFCheck()` accepts a request carrying an `OCS-APIRequest` header when no session cookie is present (`server/lib/private/AppFramework/Http/Request.php:436`). An app password over HTTP Basic plus that header therefore reaches a regular controller with CSRF protection on. +- `tests/integration/machine-secret-api.postman_collection.json` and `run-newman.sh` already run in CI (`.github/workflows/code-quality.yml:159`, `enable-newman: true`). +- `docs/` is a Docusaurus site with `docs/tutorials/admin/`. + +## Goals / Non-Goals + +**Goals:** + +- One stable, documented path per admin job a script needs. +- No new credential type: Nextcloud app passwords, scoped by the account's admin areas. +- The document and the routes cannot drift apart unnoticed. + +**Non-Goals:** + +- Suite force revocation over the API (see D4). +- User provisioning. Nextcloud's own provisioning API and SCIM apps create users; Keepiq has no user store. +- Reading secrets, certificates or key material. The admin API is metadata only, like the admin screens. +- Moving the admin screens to the new paths in this change. + +## Decisions + +### D1: A new `/api/v1/admin/` prefix with thin controllers + +v1 endpoints: + +| Method and path | Area | Service | +|---|---|---| +| `GET /api/v1/admin` | any area | index: `apiVersion`, paths | +| `GET /api/v1/admin/members` | People | member overview (change `admin-member-overview-and-offboarding`) | +| `POST /api/v1/admin/offboarding` | People | `TeamFolderOffboardingService::offboard()` | +| `GET /api/v1/admin/suites`, `POST /api/v1/admin/suites/{id}/reinstate` | People | `EncryptionSuiteService` | +| `GET`, `PUT /api/v1/admin/policies` | Policies | `AdminSettingsService` | +| `GET /api/v1/admin/applications`, `POST .../{id}/approve`, `POST .../{id}/reject`, `DELETE .../{id}` | Applications | `ApplicationService` | +| `GET /api/v1/admin/audit` | Audit | `AuditService` | +| `GET`, `POST /api/v1/admin/compliance/reports`, `GET .../{id}` | Audit | `ComplianceReportService` | +| `GET`, `POST`, `PUT`, `DELETE /api/v1/admin/siem/sinks` | Audit | `SiemSinkService` | + +Controllers live in `lib/Controller/Admin/` and hold no logic beyond parameter mapping, so the screen and the API share one code path. Responses use the same shapes and error envelope as the existing endpoints (org ADR-050). + +Alternative considered: document the existing internal routes as the public API. Rejected: their paths are inconsistent (`/api/settings/admin` next to `/api/v1/...`) and some mix owner and admin behaviour behind one path, so freezing them would freeze that. + +Alternative considered: OCS controllers with Nextcloud's openapi-extractor. Rejected: every other Keepiq endpoint uses the ADR-050 envelope; an OCS envelope for admin only would give scripts two response shapes. + +### D2: Nextcloud credentials, scoped by admin area + +A script authenticates as a Nextcloud user: a session, or an app password over HTTP Basic with `OCS-APIRequest: true`. The guard on each endpoint is one admin area (change `admin-scoped-roles`), so the recommended setup is a service account in a group that holds only the needed areas, with one app password per integration. Revoking the app password in the account's security settings cuts the integration off. + +Alternative considered: admin tokens as Keepiq applications with admin scopes over the RFC 7523 flow. Rejected: an application is a vault owner with its own suite; making it an admin principal mixes two roles and adds a second admin credential store to secure. + +### D3: The document is checked in and contract-tested + +`docs/api/admin-v1.openapi.json` (OpenAPI 3.1) describes every v1 path, parameter, response and the auth scheme. A PHPUnit test parses `appinfo/routes.php` and the document and fails when a `/api/v1/admin` route is missing from the document or the other way round. A Newman collection `tests/integration/admin-api.postman_collection.json` runs every endpoint against the CI instance, including a refusal for a user outside the area. The docs site renders the document on an "Admin API" page. + +### D4: No force revocation over the API + +`POST /api/v1/suites/{id}/force-revoke` carries `#[PasswordConfirmationRequired]` (ADR-005): the administrator re-confirms their own password at that moment. A stored app password cannot give that proof, so the API leaves force revocation out and the index says so. Reinstatement has no such guard and is in. + +### D5: v1 only grows + +Additive fields and endpoints may land in v1. Removing or renaming a field, or changing a status code, ships as `/api/v2/admin/` next to v1, with v1 kept for at least one minor release and marked with a `Sunset` header. The index lists every served version. + +## Security and zero-knowledge + +- The server never holds plaintext in any admin flow, and the admin API adds none. It returns identifiers, statuses, counts, dates and settings. +- Stored encrypted versus plain: nothing new is stored. App passwords are Nextcloud's, hashed by Nextcloud. +- Every endpoint runs Nextcloud's admin area guard before the controller. CSRF stays on, so a logged-in browser cannot be tricked into an admin call from another site. +- Rate limiting uses `#[UserRateLimit]` on the write endpoints so a leaked app password cannot hammer them. + +## Risks / Trade-offs + +- Two paths serve the same admin action until the screens move over. Both call one service, so behaviour cannot differ. +- A service account with an app password is a standing credential. The docs page tells administrators to hold it in a secret store, ideally Keepiq's own machine API. +- The document is hand-written. The contract test catches missing paths, not wrong field types; the Newman collection covers the shapes. + +## Seed data + +None in the app. The Newman collection creates its own service account and delegation through `tests/e2e/ci-seed.sh` before it runs. + +## Migration + +None. No table or column; new routes only. `` in `appinfo/info.xml` does not need a bump for schema reasons. diff --git a/openspec/changes/admin-public-api/proposal.md b/openspec/changes/admin-public-api/proposal.md new file mode 100644 index 000000000..33b7a3332 --- /dev/null +++ b/openspec/changes/admin-public-api/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Public admin API + +## Why + +The admin screens call internal routes with unstable paths and no documentation. A script can reach them with an app password, but nothing promises they stay the same. Organisations that automate onboarding, offboarding and audits need a documented, versioned admin API. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-13 | Manage the organisation through a public admin API | The admin screens call internal REST routes that a script could reach with a Nextcloud app password, but there is no documented, versioned admin API or scoped admin token. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Public/Controllers/MembersController.cs, GroupsController.cs, CollectionsController.cs, PoliciesController.cs, OrganizationController.cs:48 import; src/Api/Dirt/Public/Controllers/EventsController.cs Note: Public REST API (organisation API key) for members, groups, collections, policies, import and events." +- Passbolt: "passbolt/passbolt_api@v5.16.0 config/routes.php:133-155 /groups CRUD, users, permissions and share routes; plugins/PassboltEe/AuditLog action log routes; plugins/PassboltCe/JwtAuthentication/config/routes.php:36 JWT login for scripted admin access Note: Everything the admin UI does goes through a documented JSON API that an admin account can script." +- Keeper: "https://docs.keeper.io/enterprise-guide/developer-tools : Commander CLI and Python SDK manage the enterprise (users, roles, teams, reports); SCIM API for provisioning" +- HashiCorp Vault: "hashicorp/vault@v2.1.1 vault/logical_system_paths.go:2899 sys/internal/specs/openapi documents every admin path; api/ Go client Note: Every admin operation is an HTTP API call; the UI and CLI are clients of it." + +## What Changes + +- A versioned admin API under `/api/v1/admin/`, with an index at `GET /api/v1/admin` that returns the API version and every path. +- v1 covers members, offboarding, policies, applications, audit events, compliance reports, SIEM sinks and suite listing and reinstatement. Each endpoint delegates to the service the admin screen already uses. +- Authentication is Nextcloud's own: a browser session, or a Nextcloud app password over HTTP Basic with the `OCS-APIRequest: true` header. A scoped admin token is an app password of a service account whose group holds only the Keepiq admin areas it needs (change `admin-scoped-roles`). +- Every endpoint is guarded by one admin area, so a token can do exactly what its account may do. +- Suite force revocation stays out of the API. It needs a fresh password confirmation that a stored token cannot give. +- An OpenAPI 3.1 document at `docs/api/admin-v1.openapi.json`, a contract test that keeps it equal to `appinfo/routes.php`, a Newman collection, and a docs page. +- A versioning rule: v1 only grows; a breaking change ships as v2 next to v1. + +## Capabilities + +### New Capabilities + +- `admin-api`: a documented, versioned HTTP API for Keepiq administration, authenticated with Nextcloud credentials and scoped by admin area. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: new thin controllers under `lib/Controller/Admin/` that call `AdminSettingsService`, `ApplicationService`, `AuditService`, `ComplianceReportService`, `SiemSinkService`, `EncryptionSuiteService`, `TeamFolderOffboardingService` and the member overview service; new routes in `appinfo/routes.php` before the SPA catch-all. +- **Frontend**: none required. The admin screens may move to the new paths later; the old routes stay. +- **Database**: none. +- **Security**: no new credential type. The API returns metadata only, never a private key blob, a secret value or ciphertext. CSRF protection stays on; script clients pass it with the `OCS-APIRequest` header as Nextcloud clients do. +- **Cross-app**: the Terraform provider (change `apps-terraform-provider`) uses this API for application resources. diff --git a/openspec/changes/admin-public-api/specs/admin-api/spec.md b/openspec/changes/admin-public-api/specs/admin-api/spec.md new file mode 100644 index 000000000..c7cd6aa18 --- /dev/null +++ b/openspec/changes/admin-public-api/specs/admin-api/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: Versioned admin API index + +The system MUST serve `GET /api/v1/admin` to any user holding at least one Keepiq admin area. The response MUST contain `apiVersion`, the list of served admin API versions and every v1 path with its method. A breaking change to a path, field or status code MUST ship as a new version next to the old one, never as a change to v1. + +#### Scenario: Script discovers the admin API + +- **GIVEN** a service account in a group delegated the "Audit and compliance" area, with a Nextcloud app password +- **WHEN** a script calls `GET /api/v1/admin` with HTTP Basic auth and the header `OCS-APIRequest: true` +- **THEN** the response MUST contain `apiVersion` `1` and the paths of the v1 admin endpoints + +### Requirement: Admin API authenticates with Nextcloud credentials and honours admin areas + +Every admin API endpoint MUST accept a Nextcloud session or a Nextcloud app password over HTTP Basic, and MUST be guarded by exactly one Keepiq admin area. A caller outside that area MUST be refused before the controller runs. CSRF protection MUST stay enabled. + +#### Scenario: Audit token cannot change policies + +- **GIVEN** a service account whose group holds only the "Audit and compliance" area +- **WHEN** a script calls `PUT /api/v1/admin/policies` with its app password +- **THEN** the response MUST be a refusal and no setting MUST change + +#### Scenario: People token offboards a leaver + +- **GIVEN** a service account whose group holds the "People and offboarding" area, leaving user `carol` and successor `dave` +- **WHEN** a script calls `POST /api/v1/admin/offboarding` with `leavingUserId` `carol` and `successorUserId` `dave` +- **THEN** the response MUST report the revoked, transferred and removed counts, as the admin screen does + +### Requirement: Admin API covers the administration jobs + +The v1 admin API MUST offer: the member overview, offboarding, suite listing and reinstatement, reading and updating policies, listing, approving, rejecting and deleting applications, reading audit events, generating and reading compliance reports, and managing SIEM sinks. Each endpoint MUST call the same service the admin screen calls. + +#### Scenario: Script approves a pending application + +- **GIVEN** a service account whose group holds the "Applications and machine access" area and a pending application `ci-runner` +- **WHEN** a script calls `POST /api/v1/admin/applications/{id}/approve` for `ci-runner` +- **THEN** `ci-runner` MUST be approved with the service account recorded as approver +- **AND** the audit trail MUST show the approval + +### Requirement: Admin API returns metadata only + +No admin API response MUST contain a private key blob, a secret value, secret ciphertext or a SIEM sink credential in plain form. Suite force revocation MUST NOT be reachable through the admin API, because it requires a fresh password confirmation. + +#### Scenario: Suite listing carries no key material + +- **GIVEN** a service account holding the "People and offboarding" area +- **WHEN** a script calls `GET /api/v1/admin/suites` +- **THEN** each suite row MUST carry id, owner, status and dates +- **AND** no row MUST contain `privateKey` + +#### Scenario: Force revocation is not offered + +- **GIVEN** any admin API caller +- **WHEN** they read the path list from `GET /api/v1/admin` +- **THEN** the list MUST NOT contain a force revocation path + +### Requirement: Admin API is documented and contract-tested + +The system MUST ship an OpenAPI 3.1 document at `docs/api/admin-v1.openapi.json` that describes every v1 admin path. An automated test MUST fail when a `/api/v1/admin` route in `appinfo/routes.php` is missing from the document, or a documented path has no route. + +#### Scenario: Undocumented route fails the build + +- **GIVEN** a developer adds a new `/api/v1/admin` route without documenting it +- **WHEN** the PHPUnit suite runs +- **THEN** the admin API contract test MUST fail and name the undocumented route diff --git a/openspec/changes/admin-public-api/tasks.md b/openspec/changes/admin-public-api/tasks.md new file mode 100644 index 000000000..75fcbf820 --- /dev/null +++ b/openspec/changes/admin-public-api/tasks.md @@ -0,0 +1,27 @@ +## 1. Endpoints + +- [ ] 1.1 Add `lib/Controller/Admin/AdminIndexController.php` with `GET /api/v1/admin` returning `apiVersion`, the served versions and every path. Verify with a PHPUnit test for the payload and the route-reachability hydra gate. +- [ ] 1.2 Add the People endpoints: members, offboarding, suite list and reinstate, each guarded by the People area. Verify with PHPUnit tests for success and for a refused Audit-only user. +- [ ] 1.3 Add the Policies endpoints (`GET`, `PUT /api/v1/admin/policies`) on `AdminSettingsService`. Verify with PHPUnit tests that validation errors match the admin screen's errors. +- [ ] 1.4 Add the Applications endpoints (list, approve, reject, delete). Verify with PHPUnit tests for each status change and the no-admin-idor hydra gate. +- [ ] 1.5 Add the Audit endpoints (audit events, compliance reports, SIEM sinks). Verify with PHPUnit tests, including that no response carries a SIEM sink secret in plain form. +- [ ] 1.6 Add `#[UserRateLimit]` to every write endpoint and leave force revocation out. Verify with a PHPUnit test that no `/api/v1/admin` route maps to `forceRevoke`. + +## 2. Contract + +- [ ] 2.1 Write `docs/api/admin-v1.openapi.json` for every v1 path, including HTTP Basic with the `OCS-APIRequest` header. Verify with an OpenAPI 3.1 schema lint in CI. +- [ ] 2.2 Add `tests/Unit/Contract/AdminApiContractTest.php` that compares the document with `appinfo/routes.php`. Verify by removing one path locally and watching the test fail. +- [ ] 2.3 Add `tests/integration/admin-api.postman_collection.json` and its seed step (service account, delegation, app password). Verify with `tests/integration/run-newman.sh` in the CI Newman job. + +## 3. Documentation + +- [ ] 3.1 Add an "Admin API" page under `docs/tutorials/admin/` that renders the document and explains the service account setup and the versioning rule. Verify with the docs build (`npm run build` in `docs/`). + +## Acceptance criteria + +- `GET /api/v1/admin` returns `apiVersion` `1` and lists every v1 path. +- A service account whose group holds only the Audit area can read audit events with an app password and is refused `PUT /api/v1/admin/policies`. +- A script can approve a pending application and offboard a leaver without touching the web interface. +- No admin API response contains a private key, a certificate private part, a secret value or ciphertext. +- The contract test fails when a v1 route and the OpenAPI document disagree. +- Force revocation is not reachable through `/api/v1/admin`. diff --git a/openspec/changes/admin-scheduled-vault-backups/.openspec.yaml b/openspec/changes/admin-scheduled-vault-backups/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-scheduled-vault-backups/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-scheduled-vault-backups/design.md b/openspec/changes/admin-scheduled-vault-backups/design.md new file mode 100644 index 000000000..7118418b5 --- /dev/null +++ b/openspec/changes/admin-scheduled-vault-backups/design.md @@ -0,0 +1,93 @@ +# Design: scheduled vault backups + +## Context + +Read at development `4c214a9d`. + +- `lib/` has no `Command` directory and `appinfo/info.xml` declares no ``: Keepiq has no `occ` command yet. +- `appinfo/info.xml:110` to `:123` lists twelve background jobs. `lib/BackgroundJob/PurgeAuditLogJob.php:44` shows the `TimedJob` pattern (`setInterval()` at `:62`). +- `lib/Migration/Version001000Date20260908000000.php:53` lists the 33 Keepiq tables in a private `TABLES` constant. +- `lib/Service/AttachmentService.php:44` stores attachment ciphertext blobs in `IAppData` under the folder `attachments` (`:60`), namespace `keepiq` (`:69`). +- `lib/Service/CertificateAuthorityService.php:66` encrypts the CA private keys with Nextcloud's `ICrypto`, which is keyed to the instance `secret` in `config.php`. `lib/Db/SiemSink.php:109` stores the SIEM HMAC secret the same way. +- ADR-003: names and URLs are stored plain so search works; secret fields are RSA ciphertext; private keys are AES-wrapped with a key derived from the master password. +- The per-user encrypted export (`openspec/specs/secret-export/spec.md`, "Encrypted Backup Export"; `src/store/modules/export.js:86`) runs in the browser, by hand, for one vault. + +## Goals / Non-Goals + +**Goals:** + +- A complete, restorable copy of every Keepiq vault on a schedule, without a Nextcloud-wide restore. +- An archive that proves its own integrity before a restore touches the database. +- An option to make archives unreadable on the server itself. + +**Non-Goals:** + +- Restoring one user's vault into a live instance. Shares, team folders and delegations link vaults; a partial restore would break those links. It can follow as its own change. +- Any plaintext in a backup. The server has none to write. +- Off-site transport. Administrators copy archives with their existing backup tooling; `keepiq:backup:list` prints the path. +- Web download of archives (see D6). + +## Decisions + +### D1: One archive per run, every table and every blob + +`BackupTableRegistry` lists the 33 tables. A PHPUnit test reads the migration's `TABLES` constant by reflection and fails when the two lists differ, so a new table can never be left out silently. The archive is a zip (`ext-zip` is a Nextcloud requirement) with `manifest.json`, `tables/.jsonl` (one row per line, written as rows are read) and `blobs/`. The manifest carries the format `keepiq-vault-backup-v1`, the app version, the schema fingerprint (sorted table and column names), the creation time, the instance id, and per file the row count and SHA-256. + +Alternative considered: a SQL dump per table. Rejected: the dump dialect differs across PostgreSQL, MySQL and SQLite, all three of which Keepiq supports. + +### D2: Optional encryption to an administrator-held public key + +The admin settings accept a PEM certificate or public key (`backup_recipient_public_key`). When set, the writer streams the zip through segmented AES-256-GCM (1 MiB segments, a nonce and tag per segment) under a random content key, and wraps that key with RSA-OAEP-SHA256 under the recipient key, the same primitives ADR-003 uses. The private key stays with the administrator; `restore` and `verify` take it with `--key-file`. Without a key, the archive holds what a database dump holds. + +Alternative considered: encrypt with Nextcloud's `ICrypto`. Rejected: the key sits in `config.php` on the same server, so it protects nothing an attacker on that server cannot read. + +### D3: A timed job with an administrator schedule + +`ScheduledVaultBackupJob` is a `TimedJob` that wakes hourly (`TIME_INSENSITIVE`) and runs when `backup_interval_hours` (default 24, minimum 1) has passed since `backup_last_run_at`. It is off until `backup_enabled` is true. Archives go to the `backups` folder in `IAppData`; the oldest beyond `backup_retention_count` (default 7) are removed after a successful run. The job records `backup_last_status` and `backup_last_error` in app config. + +### D4: Four occ commands + +| Command | Does | +|---|---| +| `keepiq:backup:create` | Runs a backup now, same code as the job | +| `keepiq:backup:list` | Name, size, time, encrypted or not, path on disk | +| `keepiq:backup:verify [--key-file=]` | Decrypts if needed, checks every checksum and the format | +| `keepiq:backup:restore [--key-file=] [--dry-run] [--force]` | Restores the archive | + +`restore` refuses unless maintenance mode is on, so no request writes while tables are replaced. It verifies the archive first and refuses a schema fingerprint that differs from the installed one. In one database transaction it empties each Keepiq table and inserts the archive rows; then it replaces the attachment blobs. `--dry-run` prints current and archive row counts per table and changes nothing. `--force` is required when the archive is older than the newest row in `keepiq_audit_log`, so an administrator cannot roll back by accident. + +### D5: Restore gives back ciphertext as it was + +After a restore: + +- every user unlocks with the master password that was valid when the backup ran, because their private key blob is wrapped with it; +- secrets written after the backup are gone, and key rotations after the backup are undone; +- CA keys and SIEM secrets are readable only with the same Nextcloud `secret`; the command probes one `ICrypto` value and warns when it fails; +- rows owned by users that no longer exist in Nextcloud are listed as a warning, not dropped. + +The command prints these points before it asks for confirmation. + +### D6: No web download + +The admin section shows status, schedule, key and the archive list, and a "Back up now" button that queues the job. It offers no download. A download link would let anyone with the admin area copy every vault's ciphertext and metadata through the browser. Reading the archive needs shell access, which already implies database access. + +## Security and zero-knowledge + +- No plaintext secret value or master password exists on the server, so none can reach an archive. +- Stored encrypted in the archive: secret fields (RSA), private keys (AES under the master password), attachment blobs and their metadata (AES-GCM), CA keys and SIEM secrets (`ICrypto`). Stored plain in the archive: user ids, secret names and URLs, folder and team folder structure, audit entries, dates, statuses. +- With a recipient key set, the whole archive is ciphertext on the server. +- Restore never asks for, derives or stores a master password or a user private key. + +## Risks / Trade-offs + +- Archives double the disk use of Keepiq data per retained copy. The section shows the total size; retention is configurable. +- A long backup on a large instance holds a read over every table. Rows are streamed, not loaded, and the job runs time-insensitive. +- Restoring rolls back every vault, including changes users made after the backup. The dry run and the `--force` rule make that explicit. + +## Seed data + +None. PHPUnit tests write an archive from fixture rows into a temporary `IAppData` mock and restore it into SQLite. The seeded development vault (`lib/Repair/SeedDevelopmentData.php`) is enough for a manual `keepiq:backup:create` on the dev instance. + +## Migration + +No table or column. `appinfo/info.xml` gains the new `` entry and a `` block, so `` must be bumped for existing installs to register the job. diff --git a/openspec/changes/admin-scheduled-vault-backups/proposal.md b/openspec/changes/admin-scheduled-vault-backups/proposal.md new file mode 100644 index 000000000..d05f6404b --- /dev/null +++ b/openspec/changes/admin-scheduled-vault-backups/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Scheduled vault backups + +## Why + +Keepiq has no backup of its own. An instance relies on the Nextcloud database backup, and restoring that restores everything else too. Administrators want scheduled Keepiq backups they can restore on their own, from the command line. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-27 | Administrators schedule automatic encrypted backups of every vault on the server and restore them from the command line | There is no scheduled server-side backup of all vaults and no restore command; an instance relies on the Nextcloud database backup. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +- featureRequest: https://community.bitwarden.com/t/adjusting-timezone-and-database-backup-schedule-in-bitwarden/59341 + +### Competitors rated yes + +- Nextcloud Passwords: "marius-wieschollek/passwords@2026.9.0 src/lib/Cron/BackupJob.php:47 backup/interval; src/lib/Helper/AppSettings/BackupSettingsHelper.php:35-37 interval, max files, auto-restore after update; src/lib/Command/BackupRestoreCommand.php:45 passwords:backup:restore ..." + +### What a server backup can hold + +The server never holds plaintext secret values or master passwords (ADR-003). A server-side backup of every vault can therefore only contain ciphertext plus the metadata the server already stores in plain form. Restoring it gives back ciphertext that still needs each user's own key. + +## What Changes + +- A background job writes a backup archive of every Keepiq table and every attachment blob on a schedule the administrator sets (default: daily, keep 7). +- The archive holds ciphertext and metadata exactly as stored, with a manifest of row counts and checksums. +- Optionally, the administrator uploads a backup public key; each archive is then encrypted to it, and only the matching private key, held off the server, can open it. +- Four `occ` commands: `keepiq:backup:create`, `keepiq:backup:list`, `keepiq:backup:verify` and `keepiq:backup:restore`. Restore needs maintenance mode, checks the schema version, and supports `--dry-run`. +- A "Vault backups" section in the admin settings: schedule, retention, public key, last result and the list of archives. Archives are not downloadable from the web. +- Backup runs, failures and restores are audited. + +## Capabilities + +### New Capabilities + +- `vault-backups`: scheduled, ciphertext-only backups of every vault on the server, with verification and restore from the command line. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: `lib/Backup/` (table registry, archive writer and reader, archive encryption), `lib/BackgroundJob/ScheduledVaultBackupJob.php`, the first `lib/Command/` classes, settings keys in `AdminSettingsService`, three audit event types. +- **Frontend**: `VaultBackupSection.vue` in the admin settings. +- **Database**: none. Archives live in the app's data folder; status lives in app config. +- **Security**: an archive is as sensitive as the database it copies, so the optional public key encryption is recommended. No new plaintext exists anywhere. Restoring never needs or learns a master password. +- **Cross-app**: none. Application vaults are backed up like user vaults; an application still decrypts with its own key after a restore. diff --git a/openspec/changes/admin-scheduled-vault-backups/specs/vault-backups/spec.md b/openspec/changes/admin-scheduled-vault-backups/specs/vault-backups/spec.md new file mode 100644 index 000000000..c0494dc52 --- /dev/null +++ b/openspec/changes/admin-scheduled-vault-backups/specs/vault-backups/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Administrator schedules vault backups + +The system MUST let an administrator switch scheduled backups on in the Keepiq admin settings, choose the interval in hours (default 24, minimum 1) and the number of archives to keep (default 7). When on, a background job MUST write one archive per interval to the app data folder and MUST remove the oldest archives beyond the retention count after a successful run. The section MUST show the last run time, its result and the archive list. Every run MUST be audited as `BACKUP_CREATED` or `BACKUP_FAILED`. + +#### Scenario: Daily backup appears + +- **GIVEN** an administrator switched on "Vault backups" with interval 24 and retention 7 in the Keepiq admin settings +- **WHEN** 24 hours pass and Nextcloud cron runs +- **THEN** a new archive MUST appear in the "Vault backups" archive list +- **AND** a `BACKUP_CREATED` audit event MUST be recorded + +### Requirement: Archives hold ciphertext and metadata only + +Each archive MUST contain every Keepiq table and every attachment blob exactly as stored, plus a manifest with the format `keepiq-vault-backup-v1`, the app version, the schema fingerprint, and a row count and SHA-256 per file. An archive MUST NOT contain any plaintext secret value, master password or unwrapped private key. The table list MUST be checked against the schema by an automated test. + +#### Scenario: Secret values stay ciphertext in the archive + +- **GIVEN** vault owner `alice` has a secret whose value is `YOUR_TOKEN_HERE` +- **WHEN** an administrator runs `occ keepiq:backup:create` and inspects the archive +- **THEN** the archive MUST contain `alice`'s secret row with RSA ciphertext in its value fields +- **AND** the string `YOUR_TOKEN_HERE` MUST NOT occur anywhere in the archive + +### Requirement: Archives can be encrypted to an administrator-held key + +When a backup public key is configured, each archive MUST be encrypted with a random AES-256-GCM content key that is wrapped with RSA-OAEP-SHA256 under that public key. The server MUST NOT hold the matching private key. Verify and restore MUST require the private key through `--key-file`. + +#### Scenario: Archive cannot be read without the private key + +- **GIVEN** an administrator uploaded a backup public key and a backup ran +- **WHEN** they run `occ keepiq:backup:verify ` without `--key-file` +- **THEN** the command MUST fail and say the archive is encrypted + +### Requirement: Archives are verified and restored from the command line + +The system MUST offer `occ keepiq:backup:list`, `occ keepiq:backup:verify` and `occ keepiq:backup:restore`. Restore MUST refuse unless Nextcloud maintenance mode is on, MUST verify every checksum first, MUST refuse an archive whose schema fingerprint differs from the installed schema, and MUST replace all Keepiq tables in one database transaction before replacing the attachment blobs. `--dry-run` MUST print per-table current and archive row counts and change nothing. Restoring an archive older than the newest audit entry MUST require `--force`. Every restore MUST be audited as `BACKUP_RESTORED`. + +#### Scenario: Restore outside maintenance mode is refused + +- **GIVEN** maintenance mode is off +- **WHEN** an administrator runs `occ keepiq:backup:restore ` +- **THEN** the command MUST exit with an error and no table MUST change + +#### Scenario: Dry run shows the difference + +- **GIVEN** maintenance mode is on and a valid archive +- **WHEN** an administrator runs `occ keepiq:backup:restore --dry-run` +- **THEN** the command MUST print current and archive row counts per table +- **AND** no table MUST change + +### Requirement: A restore returns ciphertext that still needs each user's key + +After a restore, every user MUST unlock with the master password that was valid when the archive was written, and MUST read the values as they were then. The restore command MUST state before it asks for confirmation that later changes are lost, that users need their master password from backup time, and that CA keys need the same Nextcloud instance secret. Restore MUST NOT ask for, derive or store any master password or user private key. + +#### Scenario: User unlocks the restored vault + +- **GIVEN** a backup ran, then vault owner `alice` changed a secret value +- **WHEN** an administrator restores that backup and `alice` unlocks on the lock screen at `/lock` with her master password from backup time +- **THEN** `alice` MUST see the secret value from before her change + +### Requirement: Archives are not downloadable from the web + +The admin settings MUST NOT offer a download of a backup archive, and no Keepiq HTTP endpoint MUST serve archive content. + +#### Scenario: Admin section lists without download + +- **GIVEN** an administrator on the "Vault backups" section with three archives +- **WHEN** they look at the archive list +- **THEN** each row MUST show name, size, time and whether it is encrypted +- **AND** no row MUST offer a download diff --git a/openspec/changes/admin-scheduled-vault-backups/tasks.md b/openspec/changes/admin-scheduled-vault-backups/tasks.md new file mode 100644 index 000000000..cb2effd14 --- /dev/null +++ b/openspec/changes/admin-scheduled-vault-backups/tasks.md @@ -0,0 +1,32 @@ +## 1. Archive + +- [ ] 1.1 Add `lib/Backup/BackupTableRegistry.php` with the 33 tables and a test that compares it with the migration's `TABLES` by reflection. Verify with that PHPUnit test. +- [ ] 1.2 Add the archive writer (zip, JSON lines per table, blobs, manifest with row counts, SHA-256 and schema fingerprint), streaming rows. Verify with a PHPUnit test that writes fixture rows and checks every checksum. +- [ ] 1.3 Add segmented AES-256-GCM archive encryption with the content key wrapped by RSA-OAEP-SHA256 under the configured public key. Verify with a PHPUnit round-trip test and a test that a wrong key fails. + +## 2. Schedule and settings + +- [ ] 2.1 Add the settings keys (`backup_enabled`, `backup_interval_hours`, `backup_retention_count`, `backup_recipient_public_key`) with validation in `AdminSettingsService`. Verify with a PHPUnit test for bounds and key parsing. +- [ ] 2.2 Add `ScheduledVaultBackupJob` (hourly, runs when due, retention clean-up, status in app config), register it in `appinfo/info.xml` and bump ``. Verify with a PHPUnit test for due and not-due runs and retention. +- [ ] 2.3 Add the `BACKUP_CREATED`, `BACKUP_FAILED` and `BACKUP_RESTORED` audit events with whitelisted metadata. Verify with a PHPUnit test on the dispatched metadata. + +## 3. Commands + +- [ ] 3.1 Add `keepiq:backup:create` and `keepiq:backup:list` and a `` block in `appinfo/info.xml`. Verify manually with `occ keepiq:backup:create` and `occ keepiq:backup:list` on the dev instance. +- [ ] 3.2 Add `keepiq:backup:verify` with `--key-file`. Verify with a PHPUnit command test for a good archive, a tampered file and a wrong key. +- [ ] 3.3 Add `keepiq:backup:restore` with the maintenance mode check, schema fingerprint check, single transaction, blob replacement, `--dry-run` and the `--force` rule. Verify with a PHPUnit test that restores into SQLite and compares every table. +- [ ] 3.4 Print the restore warnings (old master password, lost later changes, instance secret probe, missing users). Verify with a PHPUnit command output test. + +## 4. Admin UI + +- [ ] 4.1 Add `VaultBackupSection.vue` with schedule, retention, public key upload, last result, archive list and "Back up now", and no download action. Verify with a vitest in `tests/components/`. +- [ ] 4.2 Prove a restored vault still unlocks. Verify manually on the dev instance: create a backup, change a secret, restore, unlock as `admin` with the master password from before, and see the old value. + +## Acceptance criteria + +- With backups on, an archive of every Keepiq table and attachment blob appears in the app data folder at the chosen interval, and old archives beyond the retention count are removed. +- No archive contains a plaintext secret value or a master password. +- With a backup public key set, an archive cannot be verified or restored without the matching private key. +- `occ keepiq:backup:restore` refuses to run outside maintenance mode and refuses an archive from a different schema. +- After a restore, each user unlocks with the master password valid at backup time and reads the values as they were then. +- The web interface offers no way to download an archive. diff --git a/openspec/changes/admin-scoped-roles/.openspec.yaml b/openspec/changes/admin-scoped-roles/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-scoped-roles/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-scoped-roles/design.md b/openspec/changes/admin-scoped-roles/design.md new file mode 100644 index 000000000..d36d2212e --- /dev/null +++ b/openspec/changes/admin-scoped-roles/design.md @@ -0,0 +1,87 @@ +# Design: admin scoped roles + +## Context + +Read at development `4c214a9d`. + +- `lib/Settings/AdminSettings.php:32` extends OpenRegister's AppHost `GenericAdminSettings`, which implements `IDelegatedSettings` with `getName()` returning null (`openregister/lib/AppHost/Settings/GenericAdminSettings.php:48` and `:123`). Nextcloud can therefore delegate the whole Keepiq section, and nothing smaller. +- `appinfo/info.xml` registers one `` class and one ``. +- Nextcloud's `SecurityMiddleware` lets a request through `#[AuthorizedAdminSetting]` when the user is an admin or belongs to a group delegated that settings class (`server/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php:141` to `:156`). The attribute takes one class. +- `OCP\Settings\IManager::getAllowedAdminSettings(string $section, IUser $user)` (since 23) returns the settings a user may see, including delegated ones. +- 14 methods carry `#[AuthorizedAdminSetting(AdminSettings::class)]`: `SettingsController` (6), `CACertificateController` (5), `EncryptionSuiteController` (2, including `forceRevoke()`), `AuditController` (1). +- Inline admin checks with `IGroupManager::isAdmin()` sit in `ApplicationController`, `ApplicationRequestAdminController`, `CertificateController:80`, `ComplianceReportController:69`, `DashboardController:84`, `HoneyController:81`, `LeaseAdminController:152`, `SecretTypeController`, `SiemSinkController:71`, and in `ApplicationService`, `SettingsService` and `TeamFolderOffboardingService:140`. +- `vault_admin` is hard-coded in `lib/Service/DelegationAuthorizer.php:49` (admin handover) and `lib/Service/TeamFolderOffboardingService.php:45` (offboarding). `lib/Controller/DelegationController.php:203` `capabilities()` returns `isVaultAdmin` for `src/components/share/AdminHandoverPanel.vue`. +- `src/views/settings/Settings.vue:16` to `:30` renders every admin section in one list. + +## Goals / Non-Goals + +**Goals:** + +- A person can be given only the Keepiq admin areas they need. +- One authorisation model for endpoint guards, service checks and in-app panels. +- No second role store next to Nextcloud's. + +**Non-Goals:** + +- Per-action permissions below the area level. Five areas cover the jobs the competitors name (policies, members, audit, applications); an area can be split later without a schema change. +- Scoping a role to a subset of users or groups, like Keeper nodes. Keepiq has no organisational tree. +- A Keepiq-owned role editor. Nextcloud's "Administration privileges" page already edits delegations. + +## Decisions + +### D1: A role is a Nextcloud group delegated one or more Keepiq areas + +Keepiq registers five settings classes, each implementing `IDelegatedSettings` with a translated `getName()`: + +| Class | Area | Sections | +|---|---|---| +| `AdminSettings` | General | version, CA health and actions, attachment limits, offline cache, breach check, secret types | +| `PolicyAdminSettings` | Policies | master password, org password, rotation, session timeout, vault policies | +| `ApplicationAdminSettings` | Applications and machine access | application queue, application requests, machine leases | +| `PeopleAdminSettings` | People and offboarding | members, team offboarding, encryption suites, admin handover | +| `AuditAdminSettings` | Audit and compliance | audit log, compliance, SIEM sinks, honey alerts | + +`AdminSettings` keeps its class name, so its existing delegations keep meaning something. Each class returns the Keepiq section from `getSection()` and an ascending priority, so a full administrator sees the page in today's order. + +Alternative considered: Keepiq role tables with a permission list per role, a role editor and a middleware. Rejected: it duplicates Nextcloud's delegation, and a non-admin role holder could only use it through an in-app admin route, which the hydra admin-router gate forbids. + +### D2: Every admin endpoint names one area + +Each of the 14 attribute guards changes to its area class. Each inline `isAdmin()` check becomes `AdminAreaAuthorizer::holds($userId, ::class)`, which is true for instance admins and for users whose `getAllowedAdminSettings('keepiq', $user)` contains the class. Examples: `forceRevoke()` and `reinstate()` go to People; `ComplianceReportController` and `SiemSinkController` to Audit; `LeaseAdminController` and the approval routes to Applications. + +Alternative considered: keep `AdminSettings` on every endpoint and add a second check in the body. Rejected: two checks per endpoint drift apart, and the semantic-auth gate reads the attribute. + +### D3: The admin bundle renders one area per mount + +Each settings class provides `area` through `IInitialState` and returns the same template. `Settings.vue` renders only the sections listed for that area. `CnAdminSettingsShell` with the version card renders in the General area only. No DOM data attribute is read, per the initial-state gate. + +### D4: `vault_admin` becomes an alias with an end date + +`AdminAreaAuthorizer::holds()` also returns true for People when the user is in `vault_admin`. The admin settings show a notice while that group has members, asking the administrator to delegate the People area to a group instead. The alias is removed one minor release later; the removal is its own task. + +Alternative considered: a repair step that turns `vault_admin` into a delegation row. Rejected: Nextcloud offers no public API to create delegations, and writing its table directly bypasses its checks. + +### D5: The in-app handover asks the same question + +`DelegationController::capabilities()` returns `canHandover` from `holds($userId, PeopleAdminSettings::class)`. `DelegationAuthorizer::requireVaultAdmin()` and `TeamFolderOffboardingService::assertOffboardingAdmin()` call the same method, so the button and the enforcement can never disagree. + +## Security and zero-knowledge + +- No area grants any access to plaintext or keys. Keepiq administration never had it: the server holds no usable private key (ADR-003), and ADR-005 force revocation works without one. That stays true for every area. +- Stored plain: nothing new in Keepiq. Delegations live in Nextcloud's `authorized_groups` table. +- The guard runs in Nextcloud's middleware before any controller body. A delegated user outside an area gets the same refusal a non-admin gets today. +- `#[PasswordConfirmationRequired]` on `forceRevoke()` stays, so a People holder still re-confirms their own password. + +## Risks / Trade-offs + +- Five areas are coarser than Bitwarden's thirteen flags. The area table is the unit a later change can split. +- An existing delegation of `AdminSettings` shrinks from the whole section to the General area. The release note tells administrators to delegate the other four areas to the same group if they want the old scope. +- Moving 14 guards and the inline checks in 12 files touches many controllers. Each move is small and covered by a guard test. + +## Seed data + +None. Delegations are made on Nextcloud's own page. PHPUnit tests mock `IManager::getAllowedAdminSettings()`; the Playwright test creates a group and a delegation through `occ` in `tests/e2e/ci-seed.sh`. + +## Migration + +No table or column. `appinfo/info.xml` gains four `` entries; bump `` so existing installs pick up the new settings classes on upgrade. diff --git a/openspec/changes/admin-scoped-roles/proposal.md b/openspec/changes/admin-scoped-roles/proposal.md new file mode 100644 index 000000000..a4451e558 --- /dev/null +++ b/openspec/changes/admin-scoped-roles/proposal.md @@ -0,0 +1,57 @@ +--- +kind: code +--- + +# Admin scoped roles + +## Why + +Keepiq administration is all or nothing: a person either gets the whole Keepiq admin section or nothing, plus a hard-coded `vault_admin` group for offboarding and handover. An organisation cannot give a helpdesk only offboarding, or an auditor only the audit log. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-11 | Hand out admin roles with only the permissions a person needs | There are two coarse levers: Nextcloud's delegation of the whole Keepiq admin section, and a hard-coded vault_admin group that unlocks offboarding and admin handover. The handover now has a route, a controller call and a UI panel (f13ad8e6, closing #184), so both levers work end to end. There is still no role editor and no per-permission role, so a person cannot be given only the permissions they need: partial. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Core/AdminConsole/Enums/OrganizationUserType.cs:5 Owner, :6 Admin, :7 User, :9 Custom; src/Core/AdminConsole/Models/Data/Permissions.cs:8 13 granular permission flags (event logs, import/export, reports, collections, groups, users, policies, SSO, SCIM, account recovery); ... Note: Custom role with 13 granular permissions besides owner and admin." +- 1Password: "https://support.1password.com/custom-groups/ : custom groups with chosen administrative permissions (Business)" +- Keeper: "https://docs.keeper.io/enterprise-guide/delegated-administration : administrative permissions granted per role and scoped to nodes" +- HashiCorp Vault: "hashicorp/vault@v2.1.1 vault/policy.go:25 fine-grained capabilities per path; ui/app/components/policy-form.ts:170 policy editor; ui/app/router.js access.namespaces (Enterprise namespaces for delegated admins) ..." + +### Missing half + +admin-11 is partial. Built: Nextcloud delegation of the whole Keepiq admin section, and the `vault_admin` group for offboarding and admin handover. Missing: named admin roles with a chosen set of permissions. + +## What Changes + +- The Keepiq admin settings split into five delegable areas, each its own Nextcloud admin settings class with a name: General, Policies, Applications and machine access, People and offboarding, Audit and compliance. +- A role is a Nextcloud group. An administrator creates a group such as "Keepiq helpdesk" and delegates the areas it needs on Nextcloud's "Administration privileges" page. That page is the role editor. +- Every Keepiq admin endpoint names exactly one area in its `#[AuthorizedAdminSetting]` guard. The inline `isAdmin()` checks in nine controllers and three services move to the same area model. +- A delegated user sees only the sections of the areas they hold. +- The in-app admin handover and the offboarding action check the People and offboarding area. The `vault_admin` group keeps working as an alias for that area for one release, then is removed. +- The Keepiq admin settings show which areas exist and what each one covers. + +## Capabilities + +### New Capabilities + +- `admin-scoped-roles`: Keepiq administration split into named, delegable areas, so a Nextcloud group can hold only the areas a person needs. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: five settings classes under `lib/Settings/` implementing `IDelegatedSettings`; an `AdminAreaAuthorizer` for checks inside services and in-app panels; the guards on 14 attribute-guarded methods in four controllers and the inline admin checks move to one area each; `info.xml` lists the five classes. +- **Frontend**: the admin bundle renders only the sections of the area it is mounted for, read from initial state; `AdminHandoverPanel.vue` reads the area check instead of the `vault_admin` flag. +- **Database**: none. Nextcloud stores delegations in its own table. +- **Security**: narrower grants. Instance administrators keep every area. A delegated user cannot reach an endpoint outside their areas, because Nextcloud's middleware refuses it before the controller runs. +- **Cross-app**: none. diff --git a/openspec/changes/admin-scoped-roles/specs/admin-scoped-roles/spec.md b/openspec/changes/admin-scoped-roles/specs/admin-scoped-roles/spec.md new file mode 100644 index 000000000..9fbf9bfa4 --- /dev/null +++ b/openspec/changes/admin-scoped-roles/specs/admin-scoped-roles/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Keepiq administration is split into delegable areas + +The system MUST register five named Keepiq admin settings areas that Nextcloud can delegate separately: General, Policies, Applications and machine access, People and offboarding, and Audit and compliance. Each area MUST implement `IDelegatedSettings` with a translated name so it appears on Nextcloud's "Administration privileges" page. A role MUST be a Nextcloud group delegated one or more areas. + +#### Scenario: Administrator builds an auditor role + +- **GIVEN** an instance administrator on Nextcloud's "Administration privileges" page +- **WHEN** they delegate the Keepiq "Audit and compliance" area to group `keepiq-auditors` +- **THEN** a member of `keepiq-auditors` MUST see the audit log, compliance and SIEM sections in the Keepiq admin settings +- **AND** that member MUST NOT see the policy, application or offboarding sections + +### Requirement: Every Keepiq admin endpoint is guarded by exactly one area + +Every Keepiq endpoint that requires administration MUST be guarded by `#[AuthorizedAdminSetting]` naming exactly one area class, or by `AdminAreaAuthorizer::holds()` naming exactly one area class inside a service. Instance administrators MUST pass every guard. A user outside the area MUST be refused before the controller body runs. + +#### Scenario: Auditor cannot change policies + +- **GIVEN** a member of a group delegated only the "Audit and compliance" area +- **WHEN** they call `PUT /api/settings/admin` +- **THEN** Nextcloud MUST refuse the request and no setting MUST change + +#### Scenario: Applications holder approves an application + +- **GIVEN** a member of a group delegated only the "Applications and machine access" area and a pending application +- **WHEN** they call `POST /api/v1/applications/{id}/approve` +- **THEN** the application MUST be approved with that member recorded as approver + +#### Scenario: Instance administrator keeps every action + +- **GIVEN** an instance administrator with no Keepiq delegation +- **WHEN** they call `POST /api/v1/suites/{id}/force-revoke` after password confirmation +- **THEN** the guard MUST let the request through + +### Requirement: In-app admin actions follow the People and offboarding area + +The admin handover panel in the secret sidebar and the team offboarding action MUST be available exactly to instance administrators and holders of the "People and offboarding" area. `GET /api/v1/delegations/capabilities` MUST report `canHandover` from the same check the handover endpoint enforces. Membership of the `vault_admin` group MUST count as holding that area only until the alias is removed, and the admin settings MUST warn while that group has members. + +#### Scenario: Helpdesk member sees the handover panel + +- **GIVEN** a member of a group delegated only "People and offboarding", holding a share of a secret owned by another user +- **WHEN** they open that secret's sidebar at `/secrets/{id}` +- **THEN** the admin handover panel MUST be shown + +#### Scenario: Legacy vault_admin member is warned about + +- **GIVEN** the `vault_admin` group has one member and no area is delegated to it +- **WHEN** an instance administrator opens the Keepiq admin settings +- **THEN** the General area MUST show a notice asking to delegate the "People and offboarding" area instead diff --git a/openspec/changes/admin-scoped-roles/tasks.md b/openspec/changes/admin-scoped-roles/tasks.md new file mode 100644 index 000000000..751b7ca3f --- /dev/null +++ b/openspec/changes/admin-scoped-roles/tasks.md @@ -0,0 +1,32 @@ +## 1. Areas + +- [ ] 1.1 Add `PolicyAdminSettings`, `ApplicationAdminSettings`, `PeopleAdminSettings` and `AuditAdminSettings` under `lib/Settings/`, each implementing `IDelegatedSettings` with a translated name, the Keepiq section and an area in initial state; give `AdminSettings` its General name. Verify with a PHPUnit test per class for name, section, priority and initial state. +- [ ] 1.2 Register the four classes in `appinfo/info.xml` and bump ``. Verify manually that Nextcloud's "Administration privileges" page lists five Keepiq areas after `occ upgrade`. +- [ ] 1.3 Add `AdminAreaAuthorizer::holds()` on top of `IManager::getAllowedAdminSettings()` with the `vault_admin` alias for People. Verify with a PHPUnit test for admin, delegated user, alias member and outsider. + +## 2. Guards + +- [ ] 2.1 Move the 14 `#[AuthorizedAdminSetting(AdminSettings::class)]` guards in `SettingsController`, `CACertificateController`, `EncryptionSuiteController` and `AuditController` to their area classes. Verify with the route-auth and semantic-auth hydra gates and one guard test per controller. +- [ ] 2.2 Replace the inline `isAdmin()` checks in `ApplicationController`, `ApplicationRequestAdminController`, `LeaseAdminController` and `DashboardController` with the Applications area. Verify with PHPUnit tests where an Applications holder approves an application and an Audit holder is refused. +- [ ] 2.3 Replace the inline checks in `ComplianceReportController`, `SiemSinkController` and `HoneyController` with the Audit area, and in `CertificateController` and `SecretTypeController` with General. Verify with PHPUnit guard tests per controller. +- [ ] 2.4 Route `TeamFolderOffboardingService`, `DelegationAuthorizer` and `DelegationController::capabilities()` through `holds(PeopleAdminSettings)`. Verify with PHPUnit tests that the capabilities flag and the enforcement agree for all four user kinds. +- [ ] 2.5 Replace the admin checks in `ApplicationService` and `SettingsService` with the matching area. Verify with the no-admin-idor and unsafe-auth-resolver hydra gates. + +## 3. Frontend + +- [ ] 3.1 Render only the sections of the mounted area in `Settings.vue`, and the shell in General only. Verify with a vitest per area and the initial-state and admin-router hydra gates. +- [ ] 3.2 Switch `AdminHandoverPanel.vue` and the delegation store to `canHandover`. Verify with a vitest in `tests/store/`. +- [ ] 3.3 Add an "Admin areas" note in the General area that lists the five areas, links to "Administration privileges", and warns while `vault_admin` has members. Verify with a vitest. +- [ ] 3.4 Cover delegation end to end. Verify with a Playwright test in `tests/e2e/workflows/` where a user in a group delegated only the Audit area sees the audit sections and gets 403 from `PUT /api/settings/admin`. + +## 4. Alias removal + +- [ ] 4.1 One minor release after 1.2, remove the `vault_admin` alias and its notice. Verify with a PHPUnit test that a `vault_admin` member without a delegation is refused. + +## Acceptance criteria + +- Nextcloud's "Administration privileges" page lists five named Keepiq areas. +- A user in a group delegated only the Audit area can read the audit log and compliance reports and is refused every other Keepiq admin endpoint. +- A user in a group delegated only People can offboard, force-revoke a suite after password confirmation, and use the admin handover panel. +- An instance administrator keeps every Keepiq admin action. +- No Keepiq admin endpoint keeps an inline `isAdmin()` check or the `vault_admin` literal after task 4.1. 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..03ae8edf1 --- /dev/null +++ b/openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md @@ -0,0 +1,122 @@ +## 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 + +### Requirement: A Suite In An In-Progress Migration Cannot Be Revoked +The system MUST refuse to revoke a suite, by an administrator's force-revoke that is not marked as a compromise or by its owner, while that suite is the old or the new end of a key migration that is still `in_progress` (keepiq#803). Revoking the old end blocks the reads the owner's browser needs to re-encrypt; revoking the new end makes records that were already re-encrypted, or are being written, unreadable. Either way the migration and the vault write lock would stay `in_progress` with no way to finish. The refusal MUST happen before anything is changed, MUST answer `409` with `error: migration_in_progress`, and MUST say that the migration has to be completed or aborted first. + +A force-revoke marked as a compromise (`markCompromised: true`) is the exception. The owner's abort is refused once any record has moved, and every migration route is owner-only, so whoever holds the session and the leaked password could otherwise keep the administrator's containment blocked for good by leaving a migration open. A compromise force-revoke therefore MUST NOT be refused for an in-progress migration. Instead it MUST revoke the migration's other end as compromised too, because during a compromise either end may be the one the attacker controls, and only then end the migration (status `terminated`, which releases the write lock and unlocks the SecretRequests the migration locked, leaving them on the old suite). A SecretRequest whose suite is no longer `active` MUST NOT be shown or filled through its public link: the fill page would otherwise hand out the certificate of a suite revoked as compromised, possibly one whose private key the attacker holds. This holds whether or not a migration was open. Ending it last means that if revoking the other end fails, a retry of the force-revoke still finds the open migration and completes the containment. + +#### Scenario: Force-revoke of a suite mid-migration is refused +@e2e exclude Server-side refusal on an admin API route; covered by PHPUnit on EncryptionSuiteController and MigrationService. +- **GIVEN** user A's suite is the old or the new end of a migration in state `in_progress` +- **WHEN** an administrator force-revokes that suite +- **THEN** the system MUST refuse with `409` and `error: migration_in_progress` +- **AND** the suite, its emergency contacts and the migration MUST be unchanged + +#### Scenario: A compromise force-revoke ends an open migration instead of being blocked +@e2e exclude Server-side admin API behaviour; covered by PHPUnit on EncryptionSuiteController and MigrationService. +- **GIVEN** someone holding user A's session and leaked password started a compromise recovery, committed one record so that abort is refused, and left the migration `in_progress` +- **WHEN** an administrator force-revokes either suite of that migration with `markCompromised: true` +- **THEN** the system MUST revoke the suite without a `409` +- **AND** it MUST revoke the migration's other suite as compromised as well +- **AND** only then it MUST set the migration to `terminated`, releasing the write lock and unlocking the SecretRequests the migration locked + +#### Scenario: A request on a revoked suite cannot be filled +@e2e exclude Server-side refusal on a public endpoint; covered by PHPUnit on SecretRequestPolicy and SecretRequestFillController. +- **GIVEN** a SecretRequest whose suite was force-revoked as compromised, with or without an open migration +- **WHEN** someone opens or submits its public fill link +- **THEN** the system MUST refuse with `410` and reason `unavailable` +- **AND** MUST NOT return that suite's certificate + +#### Scenario: A failed containment step can be retried +@e2e exclude Server-side failure handling; covered by PHPUnit on EncryptionSuiteController. +- **GIVEN** a compromise force-revoke revoked one end of an in-progress migration, and revoking the other end failed +- **WHEN** the administrator runs the same force-revoke again +- **THEN** the system MUST still find the in-progress migration, revoke the other end, and terminate the migration + +#### Scenario: The owner's revoke of a suite mid-migration is refused +@e2e exclude Server-side refusal; covered by PHPUnit on EncryptionSuiteController. +- **GIVEN** user A's suite is part of a migration in state `in_progress` +- **WHEN** A revokes that suite +- **THEN** the system MUST refuse with `409` and `error: migration_in_progress` + +#### Scenario: A finished migration does not block revocation +@e2e exclude Server-side check; covered by PHPUnit on MigrationService. +- **GIVEN** every migration the suite was part of is `completed`, `completed_with_errors` or `aborted` +- **WHEN** the suite is revoked +- **THEN** the migration check MUST NOT refuse it diff --git a/openspec/changes/admin-suite-revocation/specs/secret-requests/spec.md b/openspec/changes/admin-suite-revocation/specs/secret-requests/spec.md new file mode 100644 index 000000000..4b1a0d4e6 --- /dev/null +++ b/openspec/changes/admin-suite-revocation/specs/secret-requests/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: A Request Sealed To An Inactive Suite Cannot Be Filled +A SecretRequest is sealed to the EncryptionSuite it was created for, and its fill-in link hands that suite's certificate to whoever opens it. When that suite is no longer `active` (revoked, flagged compromised, or gone), a value submitted through the link would be encrypted to a key that may be in someone else's hands. The system MUST therefore refuse to show or fill a `pending` request whose suite is not `active`: it MUST answer `410` with reason `unavailable`, MUST NOT return the suite's certificate, and MUST leave the request unchanged. + +This is in addition to the existing fill-in refusals (see Requirement: Fill In via Link): `not-found`, `expired`, `fulfilled`, `declined` and `locked`. It applies however the suite stopped being active, including a compromise force-revoke that terminated a migration and unlocked the request on its old suite (see encryption-suites: Administrator Force-Revocation). A suite that cannot be found counts as not active. + +#### Scenario: A pending request on a revoked suite +@e2e exclude Server-side policy refusal; covered by PHPUnit on SecretRequestPolicy, SecretRequestService and SecretRequestFillController, and vitest on the fill page. +- **GIVEN** a SecretRequest in state `pending` whose suite is `revoked` or `compromised` +- **WHEN** someone opens or submits its fill-in link +- **THEN** the system MUST refuse with `410` and reason `unavailable` +- **AND** MUST NOT return the suite's certificate +- **AND** the request MUST remain `pending` + +#### Scenario: A request unlocked by a compromise termination stays closed +@e2e exclude Server-side listener and policy chain; covered by PHPUnit running the real unlock and the real policy. +- **GIVEN** a compromise force-revoke terminated a migration, and the termination unlocked the request back to `pending` on the old suite +- **WHEN** someone opens or submits its fill-in link +- **THEN** the system MUST refuse with `410` and reason `unavailable` diff --git a/openspec/changes/admin-suite-revocation/specs/user-sharing/spec.md b/openspec/changes/admin-suite-revocation/specs/user-sharing/spec.md new file mode 100644 index 000000000..fa9269deb --- /dev/null +++ b/openspec/changes/admin-suite-revocation/specs/user-sharing/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: EncryptionSuite Compromise — Shared Copy Migration and Owner Notification +When a recipient's EncryptionSuite is **replaced due to compromise**, the suite migration process (see encryption-suites spec) MUST cover all `Secret` rows encrypted with the old suite — including shared copies held by the recipient. Those copies MUST be re-encrypted with the new suite and flagged `possibly_compromised_at` as part of the standard migration. + +The additional responsibility of User Sharing is: when a shared copy is flagged `possibly_compromised_at` during migration, the **original owner of the secret MUST be notified** that the secret may have been compromised and its value should be replaced. The notification MUST point at the SOURCE secret, which the owner can open, not at the recipient's copy. + +The SOURCE secret MUST itself be stamped `possibly_compromised_at` (when not already stamped) and carry a `suite_compromise` rotation flag. It is not sealed under the recipient's suite, so nothing else in the migration marks it, and without the stamp it does not appear in the owner's rotation and compliance views. The same holds when the recipient's suite is force-revoked as compromised (see encryption-suites: Administrator Force-Revocation). + +A shared copy whose source no longer exists falls back to the copy and its holder. Any other failure to resolve the source MUST be logged, and a failure to mark one secret MUST NOT stop the cascade for the others. + +When the owner replaces the secret value, sync-on-update (see Requirement: Sync on Update) propagates the new value to all copies, including the migrated copy in the recipient's new suite. Updating the value MUST unset `possibly_compromised_at` on all copies. + +#### Scenario: Shared copy flagged during migration +- GIVEN user B holds a shared copy of a secret owned by A +- WHEN B's EncryptionSuite is replaced due to compromise and the copy is migrated +- THEN the copy MUST be flagged `possibly_compromised_at` (per encryption-suites migration) +- AND A MUST receive a Nextcloud notification: "A secret you shared may have been compromised — please replace its value", pointing at A's source secret +- AND A's source secret MUST be stamped `possibly_compromised_at` and flagged for rotation + +#### Scenario: Shared copy on a force-revoked compromised suite +- GIVEN user B holds a shared copy of a secret owned by A +- WHEN an administrator force-revokes B's EncryptionSuite with `markCompromised: true` +- THEN both the copy and A's source secret MUST be stamped `possibly_compromised_at` and flagged for rotation +- AND A MUST be notified about the source secret + +#### Scenario: Source secret is gone +- GIVEN a compromised shared copy whose source secret has been deleted +- WHEN the compromise cascade runs +- THEN the copy's holder MUST be notified about the copy +- AND nothing is logged as an error + +#### Scenario: Owner replaces possibly-compromised secret value +- GIVEN A's secret (and its shared copies) is flagged `possibly_compromised_at` +- WHEN A updates the secret value +- THEN sync-on-update MUST propagate the new value to all copies +- AND `possibly_compromised_at` MUST be unset on the original and all copies 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/admin-vault-policies/.openspec.yaml b/openspec/changes/admin-vault-policies/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-vault-policies/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-vault-policies/design.md b/openspec/changes/admin-vault-policies/design.md new file mode 100644 index 000000000..63d0c6799 --- /dev/null +++ b/openspec/changes/admin-vault-policies/design.md @@ -0,0 +1,111 @@ +# Design: admin vault policies + +## Context + +Read at development `4c214a9d`. + +Policy area today: + +- `lib/Service/AdminSettingsService.php:132` `getAdminSettings()` and `:232` `updateAdminSettings()` read and write app config keys in validated groups. `:254` `getPolicy()` serves the user-visible policy floor through `PasswordPolicyService`. +- `lib/Service/PasswordPolicyService.php:49` lists the org password policy keys; `:142` `getPolicy()` is what `GET /api/settings/policy` returns (`lib/Controller/SettingsController.php:284`, `#[NoAdminRequired]`). +- `src/components/settings/OrgPasswordPolicySection.vue:13` renders the org password policy as a `CnSettingsSection`; `src/views/settings/Settings.vue:17` mounts it. +- `lib/Event/Audit/AuditEventTypes.php:248` whitelists `before` and `after` for `PASSWORD_POLICY_UPDATED`, the pattern a policy audit follows. + +Export today: + +- Export runs in the browser. `lib/Controller/ExportController.php` `events()` records the export mode (`encrypted-backup`, `plaintext-csv`, `cxf`, `cxp`) for the session user. +- `src/store/modules/export.js:68` `reportExport()` posts to `/api/v1/export/events`, and `exportBackup()` reports before offering the download (`:93` to `:97`): a failed report aborts the export. `exportCsv()`, `exportCxf()` and `exportCxpSealed()` follow the same order. `exportGdprPackage()` does not report an export mode. + +Unlock today: + +- `src/store/modules/session.js:61` `unlock()` fetches `GET /api/v1/suites` and unwraps `privateKey` of the active suite in the browser. The CLI (`cli/internal/client/client.go:84`) and the browser extension (`browser-extension/src/lib/api.js:85`) read the same endpoint. +- `lib/Controller/EncryptionSuiteController.php:89` `index()` and `:117` `show()` return `EncryptionSuite::jsonSerialize()`, which includes `privateKey` (`lib/Db/EncryptionSuite.php:203`). `:176` `create()` stores a first suite generated in the browser. +- `lib/Service/OfflineManifestService.php:89` puts the active suite blob in the offline snapshot. +- Nextcloud offers `OCP\Authentication\TwoFactorAuth\IRegistry::getProviderStates(IUser)` (since 14), which returns every provider id with its enabled state for a user. + +Ownership today: + +- `lib/Service/SecretService.php:239` `create()` stores any `folderId` as given; `:825` `update()` can move a secret. `lib/Controller/ImportController.php:98` creates secrets in batches. +- `lib/Service/TeamFolderQueryService.php:333` `ancestorTeamFolders()` (private) walks a folder's ancestors to the team folders above it. +- `lib/Service/TeamFolderService.php:620` `resolveGrade()` computes a member's effective `read` or `write` grade; `lib/Controller/ShareController.php:438` `writeContext()` hands a write-grade member the owner-row material for a fan-out update (`folder-permission-grades` spec). +- `lib/Repair/SeedSecretTypes.php:62` seeds the types, including `login`, `api_key` and `database`. + +## Goals / Non-Goals + +**Goals:** + +- An administrator switches each vault policy on for all users or for chosen groups. +- A blocked export fails before any file is offered, in every supported client flow. +- A user without Nextcloud two-factor login cannot unlock or create a vault while the policy applies to them. +- New work logins of an in-scope user end up in a team folder, where the organisation keeps access through the existing offboarding transfer. + +**Non-Goals:** + +- Enforcing two-factor login itself. Nextcloud's own "Enforce two-factor authentication" setting stays the tool for that. +- Detecting MFA done at an external identity provider. An administrator who relies on it scopes the policy to the groups that do not use single sign-on. +- Moving existing personal secrets on the server. The server cannot re-encrypt; the user moves them in the browser. +- Blocking the GDPR access package. It is a legal right of access and stays available. + +## Decisions + +### D1: One policy service, app config keys, group scope per policy + +`VaultPolicyService` owns seven app config keys: `vault_export_disabled`, `vault_export_disabled_groups`, `vault_require_two_factor`, `vault_require_two_factor_groups`, `vault_org_ownership`, `vault_org_ownership_groups` and `vault_org_ownership_types`. `appliesTo(policy, userId)` is true when the policy is on and the group list is empty or shares a group with the user (`IGroupManager::getUserGroupIds()`). `AdminSettingsService::updateAdminSettings()` calls a new `updateVaultPolicySettings()` group; `getPolicy()` adds the three effective booleans for the session user, so the browser knows what applies without learning the group lists. + +Alternative considered: a policy table with one row per policy. Rejected: every other keepiq setting is app config, and three switches do not need a table. + +### D2: The export ban rides the existing report-before-download order + +`ExportController::events()` returns 403 with `code: export_disabled_by_policy` when the ban applies to the session user. Because every export action reports before it offers the file, the browser aborts. `ExportDialog.vue` hides the modes up front from `getPolicy()`. The GDPR package does not call `events()` and is not affected. + +Alternative considered: a new export-token endpoint that the browser must call first. Rejected: the report already sits before the download in all four modes, so a second round trip adds nothing. + +### D3: Two-factor gating withholds the wrapped private key + +When `vault_require_two_factor` applies and `IRegistry::getProviderStates()` returns no enabled provider other than `backup_codes`, the server: + +- returns the suite list and single suite without `privateKey`, adding `unlockBlocked: "two_factor_required"`; +- leaves the suite out of the offline manifest, so an offline unlock is impossible too; the browser drops its stored snapshot on this signal; +- refuses `POST /api/v1/suites` with 403 and the same code, so no first suite is created. + +`LockScreen.vue` shows "Your organisation requires two-factor login before you can open your vault" with a link to `/settings/user/security`. The CLI and the browser extension read the same endpoint and fail with the same code. + +Alternative considered: return 403 from `GET /api/v1/suites`. Rejected: other screens read suite status and certificates from that endpoint and would break for a reason that has nothing to do with them. + +### D4: Ownership policy checks the target folder on every write path + +For an in-scope user and an in-scope type, `SecretService::create()`, `update()` (when `folderId` changes) and the import batch refuse a target folder that has no team folder owned by the user among its ancestors. The check exposes `ancestorTeamFolders()` as a public query on `TeamFolderQueryService`. The refusal is 403 with `code: org_ownership_required`. Types outside `vault_org_ownership_types` stay personal. The default types are `login`, `api_key` and `database`, the credential types a tender means by work logins. + +Alternative considered: count any folder shared with the user as organisational. Rejected: a folder the user owns but never shared is still personal, and a folder owned by someone else cannot hold the user's own secret today. + +### D5: Write-grade members contribute into a team folder + +A member who owns no team folder must still be able to comply. `POST /api/v1/team-folders/{id}/secrets` accepts a new secret from a member whose effective grade on the target folder is `write`. The member's browser encrypts the value under the folder owner's certificate (write without read, as the secret request fill already does) and under every effective member's certificate, including their own. The server stores the owner row with `owner_id` set to the folder owner, registers the derived copies through `TeamFolderShareService`, and audits the creation with the member as actor. + +Alternative considered: let each member share a personal folder as their own team folder. Rejected: the organisation then depends on every user adding the right members, and offboarding transfers only work when a successor already holds a copy. + +### D6: Personal items that break the policy are listed, not moved + +The health report gains a "Not in a team folder" list for in-scope types. Its "Move to a team folder" action changes `folderId` into an owned team folder (the fan-out then runs), or, for a non-owner, contributes through D5 and deletes the personal copy after the contribution succeeds. + +## Security and zero-knowledge + +- The server never sees a plaintext value or the master password in any of these flows. The contribution in D5 carries only ciphertext encrypted in the member's browser; the server checks the grade and the folder, never the content. +- Stored plain: the policy switches, group lists and type lists (app config). Stored encrypted: nothing new; secret fields stay RSA ciphertext as today. +- The two-factor policy withholds the AES-wrapped private key. Withholding ciphertext weakens nothing, and it is the only lever that holds for every client. +- The export ban and the ownership policy govern supported clients. A tampered browser can still read what it can decrypt; the proposal says so, as the export controller docblock already does. +- A contributor can write a wrong value into the owner's row. A `write` grade already lets that member change every copy, so D5 adds no new trust. + +## Risks / Trade-offs + +- An in-scope user without two-factor login loses vault access the moment the policy is switched on. The admin section warns with the number of in-scope users without an enabled provider before saving. +- Users who rely on identity provider MFA are blocked until an administrator scopes them out. The admin section says so next to the switch. +- The ownership policy can block a user who owns no team folder and holds no `write` grade. The error names the policy and the health report lists what to do. + +## Seed data + +None. The policies ship off. PHPUnit tests mock `IRegistry` and `IGroupManager`; the Playwright test switches a policy on through the admin settings. + +## Migration + +None. No table or column; seven app config keys with defaults read on demand. `` in `appinfo/info.xml` does not need a bump for schema reasons. diff --git a/openspec/changes/admin-vault-policies/proposal.md b/openspec/changes/admin-vault-policies/proposal.md new file mode 100644 index 000000000..f0ed68937 --- /dev/null +++ b/openspec/changes/admin-vault-policies/proposal.md @@ -0,0 +1,60 @@ +--- +kind: code +--- + +# Admin vault policies + +## Why + +An organisation cannot stop users exporting their personal vault, cannot demand two-factor login before a vault opens, and cannot require that work logins live in a team folder. This change adds those three vault policies. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-10 | Enforce rules such as two-factor login or a ban on exporting personal vaults | Keepiq has no policy to require two-factor login or to block personal vault export. Nextcloud can enforce two-factor for the whole login, which also guards keepiq, but that is a server setting, not a vault rule. | +| admin-22 | Require that work logins are kept in the organisation's vault rather than in personal vaults | Every secret starts in the creator's personal vault; nothing forces work logins into a team folder. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +- admin-22, tender: https://www.tenderned.nl/aankondigingen/overzicht/295007 +- admin-10: no demand row. + +### Competitors rated yes + +- Bitwarden (admin-10): "bitwarden/server@v2026.9.1 src/Core/AdminConsole/Enums/PolicyType.cs:5 TwoFactorAuthentication, :19 DisablePersonalVaultExport; bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/two-factor-authentication.component.ts; ... Note: Require two-step login and remove individual vault export are among 23 policies." +- 1Password (admin-10): "https://support.1password.com/team-policies/ : 'Two-factor authentication' enforcement and further sign-in, sharing and file policies" +- Keeper (admin-10): "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies : 2FA enforcement ('it cannot be disabled by the user') and Import and Export restriction ('RESTRICT_EXPORT')" +- Bitwarden (admin-22): "bitwarden/server@v2026.9.1 src/Core/AdminConsole/Enums/PolicyType.cs:10 OrganizationDataOwnership, :52 'Enforce organization data ownership'; ... apps/web/src/locales/en/messages.json:8554 'Require all items to be owned by an organization, removing the option to store items at the account level' ..." + +### Scope of admin-10 + +The decision specifies the vault half only: a policy that blocks personal vault export, and a policy that requires the user to have Nextcloud two-factor login enabled before the vault unlocks. Enforcing two-factor login itself stays with Nextcloud. + +## What Changes + +- Three vault policies in the admin settings, next to the org password policy. Each is off by default and can be scoped to Nextcloud groups (empty means every user). +- **Block personal vault export.** `POST /api/v1/export/events` refuses the encrypted backup, plaintext CSV, CXF and CXP modes for an in-scope user. The browser already aborts the download when that report fails, and the export dialog hides the blocked modes. The GDPR access package stays available. +- **Require Nextcloud two-factor login before unlock.** For an in-scope user without an enabled Nextcloud two-factor provider (backup codes do not count), the server withholds the wrapped private key from the suite endpoints and the offline manifest, and refuses to create a first suite. The lock screen explains why and links to the Nextcloud security settings. +- **Keep work logins in team folders.** For an in-scope user and in-scope secret types (default `login`, `api_key`, `database`), creating, importing or moving a secret outside a team folder subtree the user owns is refused. +- A member with a `write` grade can save a new secret straight into a team folder they do not own. Their browser encrypts the value for the folder owner and every member; the server authorises on the grade. +- In-scope users see which of their personal items break the ownership policy, with a move action. +- Every policy change is audited with a before and after snapshot. + +## Capabilities + +### New Capabilities + +- `vault-policies`: organisation-wide vault rules an administrator switches on per group: an export ban, two-factor login before unlock, and team folder ownership of work logins. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: a new `VaultPolicyService` (read, scope check, update, audit); checks in `ExportController::events()`, `EncryptionSuiteController::index()`, `show()` and `create()`, `OfflineManifestService`, `SecretService::create()` and `update()`, and `ImportController::batchCreate()`; a new `POST /api/v1/team-folders/{id}/secrets` for write-grade contributions; the policy keys join `GET /api/settings/policy`. +- **Frontend**: a new `VaultPolicySection.vue` in the admin settings; `ExportDialog.vue` hides blocked modes; `LockScreen.vue` shows the two-factor notice; the secret form restricts the folder picker; the health report lists personal items that break the ownership policy. +- **Database**: none. The policies are app config keys. +- **Security**: the export ban and the ownership policy govern the supported clients; a tampered client can still read what it can decrypt, as the export audit already states. The two-factor policy withholds ciphertext the user needs to unlock, so it holds for every client, including the CLI and the browser extension. +- **Cross-app**: none. OpenConnector and other application vaults are not users and are outside every policy. diff --git a/openspec/changes/admin-vault-policies/specs/vault-policies/spec.md b/openspec/changes/admin-vault-policies/specs/vault-policies/spec.md new file mode 100644 index 000000000..f96854df5 --- /dev/null +++ b/openspec/changes/admin-vault-policies/specs/vault-policies/spec.md @@ -0,0 +1,99 @@ +## ADDED Requirements + +### Requirement: Administrator configures vault policies per group + +The system MUST offer three vault policies in the Keepiq admin settings: a personal vault export ban, a two-factor login requirement before unlock, and team folder ownership of work logins. Each policy MUST be off by default and MUST apply either to every user or to members of administrator-chosen Nextcloud groups. Only an administrator MUST be able to change them through `PUT /api/settings/admin`. Every change MUST dispatch one audit event carrying a before and after snapshot. `GET /api/settings/policy` MUST return, for the session user, whether each policy applies to them, and MUST NOT return the group lists. + +#### Scenario: Administrator scopes the export ban to a group + +- **GIVEN** an administrator on the Keepiq admin settings page +- **WHEN** they switch on "Block personal vault export" for group `staff` in the "Vault policies" section and save +- **THEN** `GET /api/settings/policy` MUST report the export ban as applying for a member of `staff` +- **AND** it MUST report the ban as not applying for a user outside `staff` +- **AND** one policy audit event with the before and after values MUST be recorded + +### Requirement: Personal vault export can be blocked + +When the export ban applies to a user, `POST /api/v1/export/events` MUST refuse the modes `encrypted-backup`, `plaintext-csv`, `cxf` and `cxp` with 403 and code `export_disabled_by_policy`, and the browser MUST NOT offer the export file. The export dialog MUST hide the blocked modes. The GDPR access package MUST stay available. + +#### Scenario: Blocked user gets no backup file + +- **GIVEN** the export ban applies to vault owner `erin` +- **WHEN** `erin` opens the export dialog from the secret list at `/secrets` +- **THEN** the encrypted backup and CSV options MUST NOT be offered +- **AND** a direct `POST /api/v1/export/events` with mode `encrypted-backup` MUST return 403 with code `export_disabled_by_policy` + +#### Scenario: GDPR package still downloads + +- **GIVEN** the export ban applies to vault owner `erin` +- **WHEN** `erin` requests her GDPR data package from the user settings +- **THEN** the package MUST download + +### Requirement: Vault unlock requires Nextcloud two-factor login + +When the two-factor policy applies to a user and Nextcloud reports no enabled two-factor provider for them other than backup codes, the system MUST omit `privateKey` from `GET /api/v1/suites` and `GET /api/v1/suites/{id}` and MUST add `unlockBlocked` with value `two_factor_required`. It MUST leave the suite out of `GET /api/v1/offline/manifest` and MUST refuse `POST /api/v1/suites` with 403 and code `two_factor_required`. The lock screen MUST explain the reason and link to the Nextcloud security settings. Enforcing two-factor login itself MUST stay with Nextcloud. + +#### Scenario: User without two-factor login cannot unlock + +- **GIVEN** the two-factor policy applies to vault owner `frank` and `frank` has no two-factor provider enabled +- **WHEN** `frank` opens the lock screen at `/lock` and enters his master password +- **THEN** the vault MUST stay locked +- **AND** the lock screen MUST show that the organisation requires two-factor login, with a link to `/settings/user/security` +- **AND** `GET /api/v1/suites` MUST return his suite without `privateKey` + +#### Scenario: Enabling two-factor login restores access + +- **GIVEN** the two-factor policy applies to vault owner `frank` and he enables a TOTP provider in Nextcloud +- **WHEN** `frank` enters his master password on the lock screen +- **THEN** the vault MUST unlock + +#### Scenario: The CLI gets the same answer + +- **GIVEN** the two-factor policy applies to vault owner `frank`, who has no two-factor provider enabled +- **WHEN** `frank` runs `keepiq list` with an app password +- **THEN** the CLI MUST exit with an error naming `two_factor_required` + +### Requirement: Work logins are kept in team folders + +When the ownership policy applies to a user, the system MUST refuse to create, import or move a secret of an in-scope type (default `login`, `api_key` and `database`) into a folder that has no team folder owned by that user among its ancestors. The refusal MUST be 403 with code `org_ownership_required`. Secrets of other types MUST stay unaffected. + +#### Scenario: Personal login refused + +- **GIVEN** the ownership policy applies to vault owner `gina` +- **WHEN** `gina` calls `POST /api/v1/secrets` for a `login` secret in her personal folder `Private` +- **THEN** the response MUST be 403 with code `org_ownership_required` +- **AND** no secret MUST be stored + +#### Scenario: Exempt type stays personal + +- **GIVEN** the ownership policy applies to vault owner `gina` with the default types +- **WHEN** `gina` saves a `card` secret in her personal folder `Private` +- **THEN** the secret MUST be stored + +### Requirement: Write-grade members save new secrets into a team folder + +The system MUST let a member whose effective grade on a team folder is `write` create a new secret in that folder through `POST /api/v1/team-folders/{id}/secrets`. The request MUST carry the value encrypted in the member's browser under the folder owner's certificate and under every effective member's certificate. The server MUST store the owner row as owned by the folder owner, MUST register a derived copy per member, MUST audit the creation with the member as actor, and MUST NOT decrypt any blob. A member with a `read` grade and a non-member MUST be refused. + +#### Scenario: Member saves a work login into the team folder + +- **GIVEN** `hank` holds a `write` grade on team folder `Ops`, owned by `iris`, with members `hank` and `jack` +- **WHEN** `hank` saves a new `login` secret into `Ops` from the secret form +- **THEN** `iris` MUST own the stored secret +- **AND** `hank` and `jack` MUST each receive a copy they can decrypt +- **AND** the audit trail MUST show `hank` as the actor of the creation + +#### Scenario: Read-grade member is refused + +- **GIVEN** `jack` holds a `read` grade on team folder `Ops` +- **WHEN** `jack` calls `POST /api/v1/team-folders/{id}/secrets` for `Ops` +- **THEN** the response MUST be 403 and no secret MUST be stored + +### Requirement: Users see personal items that break the ownership policy + +When the ownership policy applies to a user, the health report MUST list their secrets of in-scope types that sit outside a team folder, and MUST offer a move action into a team folder they own or can contribute to. + +#### Scenario: Existing personal login is listed + +- **GIVEN** vault owner `gina` has an older `login` secret in folder `Private` and the ownership policy now applies to her +- **WHEN** `gina` opens the health report +- **THEN** the "Not in a team folder" list MUST show that secret with a "Move to a team folder" action diff --git a/openspec/changes/admin-vault-policies/tasks.md b/openspec/changes/admin-vault-policies/tasks.md new file mode 100644 index 000000000..ba64627c5 --- /dev/null +++ b/openspec/changes/admin-vault-policies/tasks.md @@ -0,0 +1,34 @@ +## 1. Policy settings + +- [ ] 1.1 Add `VaultPolicyService` with the policy keys, validation, `appliesTo()` and a `VAULT_POLICY_UPDATED` audit event (before and after snapshot, whitelisted in `AuditEventTypes`). Verify with a PHPUnit test for group scope, invalid types and the audit metadata. +- [ ] 1.2 Wire `updateVaultPolicySettings()` into `AdminSettingsService::updateAdminSettings()` and add the effective booleans for the session user to `getPolicy()`. Verify with a PHPUnit test that `getPolicy()` never returns the group lists. +- [ ] 1.3 Add `VaultPolicySection.vue` next to `OrgPasswordPolicySection.vue`, with group pickers (`NcSelect` with `inputLabel`) and the count of in-scope users without two-factor login. Verify with a vitest in `tests/components/` and the nc-input-labels hydra gate. + +## 2. Export ban + +- [ ] 2.1 Refuse `POST /api/v1/export/events` with 403 `export_disabled_by_policy` for in-scope users. Verify with a PHPUnit test in `tests/Unit/Controller/ExportControllerTest.php` for all four modes. +- [ ] 2.2 Hide the blocked modes in `ExportDialog.vue` and keep the GDPR package. Verify with a vitest that no download starts when the report is refused. + +## 3. Two-factor before unlock + +- [ ] 3.1 Withhold `privateKey` and add `unlockBlocked` in `EncryptionSuiteController::index()` and `show()` when the policy applies and no provider other than `backup_codes` is enabled. Verify with a PHPUnit test with a mocked `IRegistry`. +- [ ] 3.2 Refuse `POST /api/v1/suites` and leave the suite out of the offline manifest under the same condition. Verify with PHPUnit tests for `create()` and `OfflineManifestService`. +- [ ] 3.3 Show the two-factor notice in `LockScreen.vue` and drop the offline snapshot on `two_factor_required`. Verify with a vitest for the lock screen and the offline store. +- [ ] 3.4 Map `two_factor_required` to a clear error in the CLI (`cli/`) and the browser extension. Verify with `go test ./...` and the extension vitest suite. + +## 4. Team folder ownership + +- [ ] 4.1 Make `ancestorTeamFolders()` a public query on `TeamFolderQueryService` and check it in `SecretService::create()`, `update()` and the import batch for in-scope users and types. Verify with PHPUnit tests for create, move and import, including an exempt type. +- [ ] 4.2 Add `POST /api/v1/team-folders/{id}/secrets` for write-grade contributions: owner row plus derived copies, grade checked with `resolveGrade()`, audit with the member as actor. Verify with PHPUnit tests for a `write` member, a `read` member and a non-member, plus the no-admin-idor hydra gate. +- [ ] 4.3 Restrict the folder picker in the secret form to owned team folders and contributable team folders for in-scope types, and add the contribution flow to the secret store. Verify with a vitest for the picker and for the per-recipient encryption. +- [ ] 4.4 Add the "Not in a team folder" list with the move action to the health report. Verify with a vitest for both the owner move and the contribution move. +- [ ] 4.5 Cover the policies end to end. Verify with a Playwright test in `tests/e2e/workflows/` where an administrator switches on the export ban and a user finds the export modes gone, and where a user in scope saves a login into a team folder after being refused a personal folder. + +## Acceptance criteria + +- With the export ban on for a user, no encrypted backup, CSV, CXF or CXP file is offered to them, and the GDPR package still downloads. +- With the two-factor policy on, a user without an enabled Nextcloud two-factor provider receives no wrapped private key from any endpoint and cannot create a first suite. +- A user who enables a two-factor provider can unlock again without an administrator action. +- With the ownership policy on, an in-scope user cannot save a `login` secret outside a team folder, and can still save a `card` secret personally. +- A write-grade member can save a new secret into a team folder they do not own, and the owner and all members can read it. +- Every policy change produces one audit event with a before and after snapshot. 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/apps-client-libraries-and-ci/.openspec.yaml b/openspec/changes/apps-client-libraries-and-ci/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/apps-client-libraries-and-ci/design.md b/openspec/changes/apps-client-libraries-and-ci/design.md new file mode 100644 index 000000000..981d4ae08 --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/design.md @@ -0,0 +1,79 @@ +# Design: client libraries and CI integrations + +## Context + +Read at development `4c214a9d`. + +- `cli/go.mod` is the module `github.com/ConductionNL/keepiq/cli`, Go 1.22, stdlib only. +- `cli/internal/client/client.go:137` `Discover()`, `:151` `MachineToken()` and `:215` `FetchByName()` implement discovery, the assertion and the by-name read; `cli/internal/crypto/crypto.go:139` `DecryptField()` and `:199` `SignRS256()` implement the crypto. They are `internal` and cannot be imported from outside `cli/`. There is no encrypt function: the CLI is read-only by design (`openspec/specs/keepiq-cli/spec.md`, "Read-only vault access in v1"). +- `cli/internal/client/client.go:201` `MachineEnvelope` expects a top-level `scheme` and `payload.value`, and `cli/ci.go:65` refuses any other scheme. The server sends `encryption.scheme` and `ciphertext.key`, `ciphertext.login`, `ciphertext.additionalFields` (`lib/Service/MachineSecretEnvelopeService.php:129` to `:150`). The CLI's unit test fakes the shape the CLI expects (`cli/internal/client/client_test.go:28`), so it cannot see the difference; only the token exchange has a live probe (`cli/internal/client/live_token_test.go`). +- `cli/internal/crypto/testdata/webcrypto_envelope.json` holds a browser-produced vector (wrapped key, field, plaintext). +- `lib/Service/DecryptService.php` and `lib/Service/EncryptService.php` are the stateless PHP crypto (ADR-003); `openspec/config.yaml` requires cross-implementation round-trip tests. +- `.github/workflows/cli-release.yml` builds six binaries and attaches them to `cli-v*` releases, with no checksum file and no container image. +- `cli/ci.go:97` `cmdCIRun()` injects values into a child process environment only; the CLI spec forbids writing decrypted values to a file. + +## Goals / Non-Goals + +**Goals:** + +- A developer in Go, Python or TypeScript reads and writes their application's secrets in a few lines, with decryption in their own process. +- One recipe, one set of vectors, byte-identical across PHP, the browser, Go, Python and TypeScript. +- A pipeline gets secrets from Keepiq with a single step and no plaintext in the pipeline configuration. + +**Non-Goals:** + +- Libraries for Java, .NET, Ruby, Rust or PHP outside Nextcloud. They can follow on the same vectors. +- User vault access in the libraries. Human access stays in the browser and the CLI's human mode. +- OIDC federation for CI (trusting GitHub or GitLab tokens instead of an application key). It would replace authentication only; decryption still needs the application private key, so the pipeline would hold that key anyway. +- A GitHub Marketplace listing, which requires a dedicated repository with `action.yml` at its root. + +## Decisions + +### D1: Libraries live in `sdk/`, one directory per language + +`sdk/go/` becomes module `github.com/ConductionNL/keepiq/sdk/go`, released with tags `sdk/go/vX.Y.Z`, stdlib only, so the CLI stays dependency free. `sdk/python/` is a `pyproject.toml` package depending only on `cryptography`. `sdk/js/` is a TypeScript package with no runtime dependency, using WebCrypto (`globalThis.crypto.subtle`, Node 20 and later, browsers). Package names are reserved at first release; the working names are `keepiq-sdk` on PyPI and `@conduction/keepiq-sdk` on npm. + +Alternative considered: one Rust core with bindings, as Bitwarden does. Rejected: three small native implementations of one documented recipe are easier to audit than a native build chain in every consumer. + +### D2: One surface in every language + +`Client(url, applicationId, privateKeyPem)` with: `getByName(name, folder?)`, `getById(id)`, `list(updatedSince?)`, `create(fields)`, `update(id, fields)`. Reads return decrypted fields plus metadata and the lease; writes encrypt with the public half of the caller's own key after checking it against the envelope fingerprint. Errors are typed: not found, ambiguous (with candidates), unauthorized, not modified. Tokens are cached until expiry; ETags are sent with `If-None-Match`. + +### D3: Parse the envelope the server sends, and prove it + +The Go extraction replaces the CLI's `MachineEnvelope` with the server's shape (`format`, `secret`, `encryption`, `ciphertext`). The conformance vectors in `sdk/testdata/` are produced from the real serializer: a PHPUnit fixture generator writes an envelope for a test application key, and the browser vector is moved from `cli/internal/crypto/testdata/`. Every library and the CLI decrypt the same vectors; a PHPUnit test decrypts vectors that each library encrypted, through `DecryptService`. The test key is a throwaway, labelled test-only and allow-listed by path for secret scanning. + +### D4: The GitHub Action runs a command by default, exports only on request + +`integrations/github-action/action.yml` is a composite action with inputs `url`, `application-id`, `private-key` (from a GitHub secret, passed as `KEEPIQ_APP_KEY`), `secrets` (names, one per line, optional `NAME=ENV_VAR`), `run` and `export-env` (default false). It downloads the CLI for the runner's OS and architecture from the matching `cli-v*` release and checks it against the release checksums. With `run`, it executes `keepiq ci run` around that command, so values never touch disk. With `export-env: true`, it masks each value with `::add-mask::` (per line for multi-line values) and appends it to `$GITHUB_ENV`; the input's description says this writes the value to the runner's environment file. With neither, the step fails with a message naming both options. + +Alternative considered: export by default, as some competitor actions do. Rejected: the CLI spec promises no plaintext on disk; export stays a deliberate choice. + +### D5: A GitLab CI template included by URL + +`integrations/gitlab-ci/keepiq.gitlab-ci.yml` defines a hidden job `.keepiq` whose `before_script` installs the checked CLI. A job `extends: .keepiq` and wraps its command with `keepiq ci run`. GitLab cannot mask values fetched at run time, so the template only offers the wrapped form. Projects include it with `include: remote:` pointing at the file on a `cli-v*` tag. A CI/CD Catalog component needs its own GitLab project and is left for later. + +### D6: Release and test per directory + +`cli-release.yml` adds `SHA256SUMS` to each `cli-v*` release and pushes `ghcr.io/conductionnl/keepiq-cli` (static binary on a distroless base). New workflows test and release each library on its own tag prefix (`sdk/go/v*`, `sdk-py-v*`, `sdk-js-v*`): Python through PyPI trusted publishing, TypeScript through npm with provenance. The action and template are tested by a workflow that runs them against a stub Keepiq server serving the vectors. + +## Security and zero-knowledge + +- The server never sees plaintext: libraries send ciphertext on write and receive ciphertext on read. No server change. +- Plain on the caller's side: decrypted values in process memory (and, with `export-env`, in the runner's environment file). Encrypted: everything that crosses the network. +- The application private key is supplied by the caller and never sent; only a signed assertion leaves the process. +- The action verifies the CLI binary's checksum before running it, so a tampered download is refused. + +## Risks / Trade-offs + +- Three libraries triple the maintenance of the recipe. The shared vectors make any drift fail CI in the language that drifted. +- Fixing the CLI's envelope parser changes CLI behaviour. Today the parser cannot read a server envelope, so no working pipeline depends on the old shape. +- The action exists only as a path in this repository, so it is not discoverable in the Marketplace. + +## Seed data + +None in the app. The PHPUnit fixture generator creates a throwaway application key and envelope for the vectors; nothing is written to a dev database. + +## Migration + +None. No server change, so no table, column or `` bump. diff --git a/openspec/changes/apps-client-libraries-and-ci/proposal.md b/openspec/changes/apps-client-libraries-and-ci/proposal.md new file mode 100644 index 000000000..a6bd8ebac --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/proposal.md @@ -0,0 +1,65 @@ +--- +kind: code +--- + +# Client libraries and CI integrations + +## Why + +A Python service, a Node job or a CI pipeline that needs a Keepiq application secret has to implement the RFC 7523 assertion, the envelope and the chunked RSA decryption itself. There is one Go CLI and nothing else. + +| Row | Capability | What keepiq does today | +|---|---|---| +| apps-18 | Use a ready-made GitHub Actions or GitLab CI integration | No ready-made GitHub Actions or GitLab CI step exists; a pipeline would have to install and script the keepiq CLI itself. | +| apps-21 | Use client libraries for common programming languages | There is one cross-compiled CLI binary, not per-language client libraries; a Python or Node consumer would call the documented HTTP+JWT API directly with no official SDK. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden (apps-18): "bitwarden/clients@web-v2026.9.0 bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts:25 GitHub Actions, :32 GitLab CI/CD, :39 Ansible | docs: https://bitwarden.com/help/github-actions-integration/ ..." +- 1Password (apps-18): "https://developer.1password.com/docs/ci-cd/github-actions/ : load secrets into GitHub Actions with secret references" +- Keeper (apps-18): "https://docs.keeper.io/keeperpam/secrets-manager/integrations/github-actions : Keeper Secrets Manager GitHub Action; GitLab integration at https://docs.keeper.io/keeperpam/secrets-manager/integrations/gitlab-plugin" +- HashiCorp Vault (apps-18): "hashicorp/vault@v2.1.1 go.mod:158 vault-plugin-auth-jwt bundled for GitHub and GitLab OIDC tokens | docs: https://developer.hashicorp.com/vault/docs/platform/github-actions (hashicorp/vault-action, separate repo) ..." +- Bitwarden (apps-21): "bitwarden/clients@web-v2026.9.0 bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts:45 C#, :51 C++, :57 Go, :63 Java, :70 JS WebAssembly, :76 php, :82 Python, :88 Ruby, :18 Rust (bitwarden/sdk-sm) | docs: https://bitwarden.com/help/secrets-manager-sdk/ ..." +- 1Password (apps-21): "https://developer.1password.com/docs/sdks/ : SDKs for Go, JavaScript and Python" +- Keeper (apps-21): "https://docs.keeper.io/keeperpam/secrets-manager/developer-sdk-library : Python, Java/Kotlin, JavaScript, .NET, Go, Ruby, Rust, PowerShell SDKs" +- HashiCorp Vault (apps-21): "hashicorp/vault@v2.1.1 api/auth.go official Go client package api/ in-tree | docs: https://developer.hashicorp.com/vault/api-docs/libraries ..." + +### Missing half + +apps-21 is partial. Built: the Go command-line client in `cli/`. Missing: client libraries for common languages. + +## What Changes + +- Three client libraries for the machine API: Go (`sdk/go/`, extracted from `cli/internal/`), Python (`sdk/python/`) and TypeScript for Node and browsers (`sdk/js/`). +- Each library discovers the instance, signs the RFC 7523 assertion, caches the token, reads by name, id and list, decrypts locally, writes back values encrypted to the application's own key, honours ETags and leases, and reports the 409 candidates. +- One set of conformance vectors in `sdk/testdata/`, produced by the PHP serializer and the browser crypto, that every library and the CLI must pass. +- The CLI moves onto the Go library. Its envelope parser follows the envelope the server actually sends. +- A GitHub Action in `integrations/github-action/`, used as `ConductionNL/keepiq/integrations/github-action@`. It runs a command with secrets in its environment, or, when the workflow opts in, exports masked values to later steps. +- A GitLab CI template in `integrations/gitlab-ci/`, included by URL, that installs the CLI and wraps a job's command with `keepiq ci run`. +- The CLI release publishes checksums and a container image `ghcr.io/conductionnl/keepiq-cli`. +- No change to the Keepiq server. + +## Capabilities + +### New Capabilities + +- `client-libraries`: official Go, Python and TypeScript libraries for the Keepiq machine API, decrypting only in the calling process. +- `ci-integrations`: a GitHub Action and a GitLab CI template that bring Keepiq application secrets into pipelines. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: none. A PHPUnit test decrypts library-produced vectors with `DecryptService` to prove the round trip. +- **Frontend**: none. +- **Database**: none. +- **Security**: plaintext exists only in the calling process or the pipeline step. The application private key never leaves the caller. The GitHub Action masks every value before any later step can print it. +- **Cross-app**: the Kubernetes operator, the rotation runner and the Terraform provider build on `sdk/go/`. diff --git a/openspec/changes/apps-client-libraries-and-ci/specs/ci-integrations/spec.md b/openspec/changes/apps-client-libraries-and-ci/specs/ci-integrations/spec.md new file mode 100644 index 000000000..1e9d8b22a --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/specs/ci-integrations/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: GitHub Action runs a step with Keepiq secrets + +The project MUST ship a composite GitHub Action at `integrations/github-action/`, usable as `ConductionNL/keepiq/integrations/github-action@`. It MUST take the instance URL, application id, application private key and a list of secret names. It MUST install the CLI for the runner from the matching release and MUST refuse a binary whose checksum does not match the release checksums. With the `run` input it MUST execute that command through `keepiq ci run`, so values exist only in that command's environment and nothing is written to disk. + +#### Scenario: Deploy step gets a database password + +- **GIVEN** a workflow with the application private key in GitHub secret `KEEPIQ_APP_KEY` +- **WHEN** a step uses the action with `secrets: DB_PASSWORD` and `run: ./deploy.sh` +- **THEN** `./deploy.sh` MUST see `KEEPIQ_DB_PASSWORD` in its environment +- **AND** no file on the runner MUST contain the value + +### Requirement: GitHub Action exports only on request and masks every value + +The action MUST NOT export values to later steps unless `export-env` is `true`. When it exports, it MUST register every value with `::add-mask::` (every line of a multi-line value) before writing it to `$GITHUB_ENV`. Without `run` and without `export-env`, the step MUST fail and name both options. + +#### Scenario: Exported value is masked in logs + +- **GIVEN** a step using the action with `secrets: API_TOKEN` and `export-env: true` +- **WHEN** a later step echoes `$KEEPIQ_API_TOKEN` +- **THEN** the workflow log MUST show the value masked + +### Requirement: GitLab CI template wraps a job command + +The project MUST ship `integrations/gitlab-ci/keepiq.gitlab-ci.yml` defining a hidden job `.keepiq` that installs the checksum-verified CLI. A job extending it MUST be able to run its command through `keepiq ci run`, so values exist only in that command's environment. + +#### Scenario: GitLab job runs a migration with a secret + +- **GIVEN** a `.gitlab-ci.yml` that includes the template by URL and a job `migrate` with `extends: .keepiq` +- **WHEN** the job runs `keepiq ci run DB_PASSWORD` with `./migrate.sh` as the wrapped command +- **THEN** `./migrate.sh` MUST see `KEEPIQ_DB_PASSWORD` in its environment + +### Requirement: The CLI release is verifiable and containerised + +Each `cli-v*` release MUST include a `SHA256SUMS` file for every binary and MUST publish the container image `ghcr.io/conductionnl/keepiq-cli` with the same version. + +#### Scenario: Pipeline verifies the downloaded CLI + +- **GIVEN** release `cli-v0.2.0` +- **WHEN** a pipeline downloads `keepiq-linux-amd64` and `SHA256SUMS` from it +- **THEN** the binary's SHA-256 MUST match its line in `SHA256SUMS` diff --git a/openspec/changes/apps-client-libraries-and-ci/specs/client-libraries/spec.md b/openspec/changes/apps-client-libraries-and-ci/specs/client-libraries/spec.md new file mode 100644 index 000000000..d0ecc3a54 --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/specs/client-libraries/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Official libraries for Go, Python and TypeScript + +The project MUST ship client libraries for the Keepiq machine API in Go (`sdk/go/`), Python (`sdk/python/`) and TypeScript (`sdk/js/`). Each MUST discover the instance from `/api/v1/app/.well-known/keepiq`, sign the RFC 7523 assertion with the caller's application private key, cache the bearer token until it expires, and offer read by name, read by id, list with an `updatedSince` filter, create and update for the application's own vault. + +#### Scenario: Python service reads a secret by name + +- **GIVEN** an approved application `billing` with its private key in `/run/secrets/keepiq.pem` and a secret `stripe-key` in its vault +- **WHEN** a Python service calls `Client(url, "billing", key).get_by_name("stripe-key")` +- **THEN** the call MUST return the decrypted value and the secret metadata +- **AND** no request from the library MUST carry the plaintext value or the private key + +### Requirement: Libraries decrypt and encrypt only in the calling process + +Every library MUST decrypt the `rsa-oaep-sha256-chunked-v1` ciphertext in the calling process, MUST check the envelope's certificate fingerprint against the caller's key before decrypting, and MUST encrypt write-back values with the public half of the caller's own key before sending them to `POST /api/v1/app/secrets` or `PUT /api/v1/app/secrets/{id}`. + +#### Scenario: Node job rotates its own value + +- **GIVEN** a TypeScript job holding the private key of application `billing` +- **WHEN** it calls `client.update(id, { key: "YOUR_TOKEN_HERE" })` +- **THEN** the request body MUST contain only ciphertext for `key` +- **AND** a later `getById(id)` MUST return `YOUR_TOKEN_HERE` + +### Requirement: Libraries follow the machine API contract + +Every library MUST send `If-None-Match` with the last ETag and report "not modified" on 304, MUST expose the `Doriath-Lease-Id` and `Doriath-Lease-Expires` headers, and MUST raise a typed ambiguous-name error carrying the candidates' ids and folder paths on 409. + +#### Scenario: Ambiguous name is reported with candidates + +- **GIVEN** two secrets named `api-token` in the application's vault +- **WHEN** a Go program calls `GetByName("api-token", "")` +- **THEN** the call MUST return an ambiguous-name error listing both candidates' ids and folder paths + +### Requirement: One set of conformance vectors binds every implementation + +The repository MUST hold shared vectors in `sdk/testdata/` produced by the PHP envelope serializer and the browser crypto. Every library and the CLI MUST decrypt them in CI, and a PHPUnit test MUST decrypt values that each library encrypted, using `DecryptService`. The CLI MUST parse the envelope shape the server sends. + +#### Scenario: CLI reads a real envelope + +- **GIVEN** an envelope written by `MachineSecretEnvelopeService::serialize()` with `encryption.scheme` and `ciphertext.key` +- **WHEN** the CLI's CI mode decrypts it with the matching application key +- **THEN** it MUST return the plaintext value + +### Requirement: Libraries are released from this repository + +Each library MUST be tested on every pull request that touches its directory and released on its own tag prefix: `sdk/go/v*` for Go, `sdk-py-v*` for PyPI through trusted publishing, and `sdk-js-v*` for npm with provenance. + +#### Scenario: Tagged Python release + +- **GIVEN** a maintainer pushes tag `sdk-py-v0.1.0` +- **WHEN** the release workflow finishes +- **THEN** version `0.1.0` of the Python package MUST be on PyPI with a trusted-publishing attestation diff --git a/openspec/changes/apps-client-libraries-and-ci/tasks.md b/openspec/changes/apps-client-libraries-and-ci/tasks.md new file mode 100644 index 000000000..b76ebe432 --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/tasks.md @@ -0,0 +1,28 @@ +## 1. Go library and vectors + +- [ ] 1.1 Extract `cli/internal/client` and `cli/internal/crypto` into module `sdk/go/`, move the CLI onto it, and keep both stdlib only. Verify with `go vet ./...` and `go test ./...` in `sdk/go/` and `cli/`. +- [ ] 1.2 Replace the envelope struct with the server's shape (`encryption.scheme`, `ciphertext.*`) and fix the CLI's scheme check. Verify with a Go test against an envelope written by `MachineSecretEnvelopeService::serialize()`, and manually with `keepiq ci fetch` against the dev instance. +- [ ] 1.3 Create `sdk/testdata/` with vectors from the PHP serializer (fixture generator in `tests/Unit/`) and the moved browser vector; allow-list the test key by path for secret scanning. Verify with the generator test and a gitleaks run. +- [ ] 1.4 Add encrypt, list with `updatedSince`, by-id, create, update, typed errors and lease reporting to `sdk/go/`. Verify with Go tests against an httptest stub and a PHPUnit test that decrypts Go-encrypted vectors with `DecryptService`. + +## 2. Python and TypeScript + +- [ ] 2.1 Build `sdk/python/` with the D2 surface on `cryptography`. Verify with `pytest` on the shared vectors and the PHPUnit round trip for Python-encrypted vectors. +- [ ] 2.2 Build `sdk/js/` in TypeScript on WebCrypto with no runtime dependency. Verify with vitest on the shared vectors in Node 20 and the PHPUnit round trip for TypeScript-encrypted vectors. +- [ ] 2.3 Add release workflows for `sdk/go/v*`, `sdk-py-v*` (PyPI trusted publishing) and `sdk-js-v*` (npm with provenance). Verify with a dry run of each workflow on a pull request. + +## 3. CI integrations + +- [ ] 3.1 Add `SHA256SUMS` and the `ghcr.io/conductionnl/keepiq-cli` image to `cli-release.yml`. Verify with a workflow dry run and `docker run ghcr.io/conductionnl/keepiq-cli --version`. +- [ ] 3.2 Add `integrations/github-action/action.yml` with the `run` and `export-env` modes, checksum check and masking. Verify with a workflow that runs the action against a stub server and asserts the value is masked in the log. +- [ ] 3.3 Add `integrations/gitlab-ci/keepiq.gitlab-ci.yml` with the `.keepiq` hidden job. Verify with `gitlab-ci-local` or a GitLab lint call in the same workflow. +- [ ] 3.4 Document the libraries, the action and the template on the docs site. Verify with the docs build in `docs/`. + +## Acceptance criteria + +- A Python script with an application id and private key reads a secret by name in under ten lines, and the value never crosses the network in plain form. +- Every library and the CLI decrypt the shared vectors, and `DecryptService` decrypts what each library encrypts. +- `keepiq ci fetch` decrypts an envelope from a real Keepiq instance. +- A GitHub workflow step using the action runs a command with a Keepiq secret in its environment and nothing is written to disk. +- With `export-env: true`, later steps see the value and the log shows it masked. +- A GitLab job extending `.keepiq` runs its command with a Keepiq secret in its environment. diff --git a/openspec/changes/apps-kubernetes-injection/.openspec.yaml b/openspec/changes/apps-kubernetes-injection/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/apps-kubernetes-injection/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/apps-kubernetes-injection/design.md b/openspec/changes/apps-kubernetes-injection/design.md new file mode 100644 index 000000000..8c20578a4 --- /dev/null +++ b/openspec/changes/apps-kubernetes-injection/design.md @@ -0,0 +1,78 @@ +# Design: Kubernetes secret injection + +## Context + +Read at development `4c214a9d`. + +- The machine API is the only surface an operator needs: discovery at `/api/v1/app/.well-known/keepiq` (`appinfo/routes.php:295`), the RFC 7523 token exchange at `/api/v1/token` (`:299`), list, by-id and by-name reads at `/api/v1/app/secrets*` (`:304` to `:309`), and leases at `/api/v1/app/leases*` (`:319` to `:321`). +- Reads return the `doriath-machine-secret-v1` envelope with ciphertext only and a strong ETag; `If-None-Match` yields 304 (`lib/Service/MachineSecretResponseService.php:98` to `:101`). Lease id and expiry arrive as `Doriath-Lease-Id` and `Doriath-Lease-Expires` headers (`:176`, `:177`). +- `cli/internal/client/client.go:137` `Discover()`, `:151` `MachineToken()` and `:215` `FetchByName()` implement discovery, the assertion and the by-name read in stdlib Go. `cli/internal/crypto/crypto.go:139` `DecryptField()` decrypts the `rsa-oaep-sha256-chunked-v1` scheme. Both packages are `internal`, so Go forbids importing them from outside `cli/`. +- `cli/ci.go:97` `cmdCIRun()` injects fetched values into a child process environment only. +- `.github/workflows/cli-release.yml` builds the CLI for six platforms and attaches binaries to `cli-v*` releases. No container image is published. +- `grep -rli 'kubernetes|k8s|helm' lib src cli browser-extension` finds nothing. + +## Goals / Non-Goals + +**Goals:** + +- A Kubernetes user gets a Keepiq application secret into a pod with one custom resource and no scripting. +- Decryption happens only inside the cluster, with an application key the cluster holds. +- A rotated value reaches the pod without a manual step. + +**Non-Goals:** + +- A mutating admission webhook that rewrites pods automatically. The recipe in D5 covers the no-Secret case by hand; a webhook can follow as its own change. +- A CSI driver. +- An External Secrets Operator provider. ESO providers live in the ESO repository and would need the RSA decryption there; that is an upstream contribution, not part of this repository. +- Reading user vaults. The operator is a machine client and sees only its application's vault. + +## Decisions + +### D1: An operator in this repository, on the shared Go SDK + +The operator is a Go module at `integrations/kubernetes/` built with controller-runtime. It imports `sdk/go/` (discovery, token, reads, lease headers, decryption), extracted from `cli/internal/` by change `apps-client-libraries-and-ci`, so the CLI, the operator, the runner and the Terraform provider share one implementation of the crypto recipe. + +Alternative considered: copy the client and crypto code into the operator. Rejected: the chunked RSA-OAEP recipe must stay byte-identical to the browser's (ADR-003, dual implementation); a second copy is a second place to break it. + +### D2: Two custom resources + +`KeepiqConnection` (namespaced) holds `url`, `applicationId` and `privateKeySecretRef` (name and key of a Kubernetes Secret with the application private key PEM). `KeepiqSecret` (namespaced) holds `connectionRef`, `target.name`, `refreshInterval` (default 60 seconds, minimum 10), optional `restartTargets` (Deployments or StatefulSets), and `items`: each item names a Keepiq secret (`name`, optional `folder`), a field (`key`, `login` or `additionalFields.`) and the key in the target Secret. + +One Keepiq application per namespace or team is the recommended layout, so a namespace can read only its own application vault. + +### D3: Reconcile loop + +Per `KeepiqSecret`: load the connection and key, get a bearer token (cached until expiry), fetch each item by name with the last ETag, decrypt changed envelopes in memory, check the envelope's certificate fingerprint against the key before decrypting, write the target Secret with an owner reference, patch a checksum annotation on each restart target when a value changed, record lease id and expiry, and requeue after `refreshInterval`. A 404 or a 409 (ambiguous name, with candidates) sets `Ready=False` with the reason and an event. Plaintext is never logged or put in status or events. + +### D4: Leases + +When discovery advertises leases, the operator renews a lease through `POST /api/v1/app/leases/{id}/renew` before it expires, and treats a refused renewal or a revoked lease as a signal to refetch on the next loop. Against an instance without leases it works unchanged. + +### D5: A no-Secret recipe with the CLI + +For pods that must not keep a value in etcd, the Helm chart documents a pod template: an init container from `ghcr.io/conductionnl/keepiq-cli` copies the static binary into an `emptyDir`, and the app container starts its original command through `/keepiq/keepiq ci run NAME1,NAME2`, which takes that command after its `--` separator. The value then exists only in the child process environment (`cli/ci.go:97`). The application key comes from a Kubernetes Secret mounted as a file (`KEEPIQ_APP_KEY_FILE`). + +### D6: Release and test + +`.github/workflows/integrations-kubernetes.yml` runs `go vet` and `go test` with envtest on pull requests touching `integrations/kubernetes/**`, and a kind cluster test against a stub Keepiq server that serves envelopes from the shared test vectors in `sdk/testdata/`. On a `k8s-v*` tag it builds a multi-arch image to `ghcr.io/conductionnl/keepiq-operator`, signs it with cosign keyless, and pushes the Helm chart as an OCI artifact to `ghcr.io/conductionnl/charts`. A live test against a real instance runs only when `KEEPIQ_LIVE_URL` is set, as the CLI's live test does. + +## Security and zero-knowledge + +- The Keepiq server never sees plaintext and never holds the application private key; nothing changes on the server. +- In the cluster: the application private key sits in a Kubernetes Secret; decrypted values sit in the target Kubernetes Secret (sync mode) or only in process memory (D5). The chart's documentation recommends etcd encryption at rest for sync mode. +- The operator's RBAC is namespaced by default: it reads `KeepiqConnection`, `KeepiqSecret` and the referenced key Secret, and writes only target Secrets it owns. A cluster-wide mode is an explicit chart value. +- Every fetch is audited on the Keepiq side as an application read, with lease id when leases are on. + +## Risks / Trade-offs + +- A Kubernetes Secret is readable by anyone with Secret read rights in the namespace. That is the cluster's access model, and D5 exists for workloads that need more. +- Polling every 60 seconds per `KeepiqSecret` adds load on large clusters. ETag reads are cheap (304, no body), and the interval is configurable. +- A new Go module with controller-runtime brings dependencies the stdlib-only CLI avoided. They stay in `integrations/kubernetes/go.mod` and never enter the CLI binary. + +## Seed data + +None in the app. The kind test registers its application against the stub server; the live test uses an application the operator of the test instance registers by hand. + +## Migration + +None. No server change, so no table, column or `` bump. diff --git a/openspec/changes/apps-kubernetes-injection/proposal.md b/openspec/changes/apps-kubernetes-injection/proposal.md new file mode 100644 index 000000000..6416fb8a0 --- /dev/null +++ b/openspec/changes/apps-kubernetes-injection/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Kubernetes secret injection + +## Why + +Teams that run workloads on Kubernetes have to script the Keepiq machine API themselves to get a credential into a pod. Every competitor that serves machine secrets ships a ready-made Kubernetes integration. + +| Row | Capability | What keepiq does today | +|---|---|---| +| apps-17 | Inject secrets into a Kubernetes cluster | No Kubernetes secret injection (operator, CSI driver, sidecar) exists; keepiq's machine surface is a plain HTTP+JWT API a cluster could call itself, but nothing ships to do that integration. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts:94 Kubernetes Operator; bitwarden/server@v2026.9.1 src/Api/SecretsManager/Controllers/SecretsController.cs:308 secrets/sync used by the operator | docs: https://bitwarden.com/help/secrets-manager-kubernetes-operator/ ..." +- 1Password: "https://developer.1password.com/docs/k8s/integrations/ : Kubernetes Secrets Injector, Operator and Helm charts" +- Keeper: "https://docs.keeper.io/keeperpam/secrets-manager/integrations/kubernetes-external-secrets-operator : External Secrets Operator provider syncs Keeper secrets into Kubernetes Secrets (also a Secrets Injector)" +- HashiCorp Vault: "hashicorp/vault@v2.1.1 go.mod:160 vault-plugin-auth-kubernetes and :173 vault-plugin-secrets-kubernetes bundled; ui/app/router.js mounts the kubernetes engine UI | docs: https://developer.hashicorp.com/vault/docs/platform/k8s/vso ..." + +## What Changes + +- A Kubernetes operator in `integrations/kubernetes/`, released as a container image and a Helm chart from this repository. +- Two custom resources: `KeepiqConnection` (instance URL, application id, and a reference to a Kubernetes Secret holding the application private key) and `KeepiqSecret` (which Keepiq secrets, which fields, which target Kubernetes Secret, how often to refresh). +- The operator exchanges an RFC 7523 assertion for a bearer token, fetches each secret by name through the machine API, decrypts it in its own process with the application private key, and writes the target Kubernetes Secret. +- It polls with `If-None-Match`, so a rotated value reaches the cluster within one refresh interval, and it can restart named Deployments when a value changes. +- It reports status conditions and events on each `KeepiqSecret`, and honours machine leases. +- A documented recipe for pods that must not keep a Kubernetes Secret: an init container copies the static `keepiq` CLI into the pod, and the container starts through `keepiq ci run`, so the value lives only in the process environment. +- No change to the Keepiq server. + +## Capabilities + +### New Capabilities + +- `kubernetes-integration`: a Kubernetes operator and an injection recipe that deliver Keepiq application secrets into pods, decrypting only inside the cluster. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: none. The operator uses the existing machine API (`/api/v1/token`, `/api/v1/app/secrets*`, `/api/v1/app/leases*`) and discovery document. +- **Frontend**: none. +- **Database**: none. +- **Security**: the Keepiq server keeps serving ciphertext only. Plaintext exists in the operator's memory and in the target Kubernetes Secret, inside the cluster the application owner controls. The application private key stays in the cluster. +- **Cross-app**: the operator imports the shared Go SDK in `sdk/go/` from change `apps-client-libraries-and-ci`; whichever change lands first extracts that module from `cli/internal/`. diff --git a/openspec/changes/apps-kubernetes-injection/specs/kubernetes-integration/spec.md b/openspec/changes/apps-kubernetes-injection/specs/kubernetes-integration/spec.md new file mode 100644 index 000000000..b28e3a3eb --- /dev/null +++ b/openspec/changes/apps-kubernetes-injection/specs/kubernetes-integration/spec.md @@ -0,0 +1,76 @@ +## ADDED Requirements + +### Requirement: Operator syncs application secrets into Kubernetes Secrets + +The Keepiq Kubernetes operator MUST, for each `KeepiqSecret` resource, authenticate as the Keepiq application named by its `KeepiqConnection` through the RFC 7523 token exchange, fetch each listed secret by name through `GET /api/v1/app/secrets/by-name/{name}`, decrypt the envelope inside the operator process with the application private key, and write the chosen fields into the target Kubernetes Secret. The Keepiq server MUST receive no plaintext and MUST NOT be changed for this. + +#### Scenario: Platform engineer syncs a database password + +- **GIVEN** a `KeepiqConnection` for application `shop-prod` with its private key in Kubernetes Secret `keepiq-app-key`, and a Keepiq secret `db-password` in the `shop-prod` vault +- **WHEN** a platform engineer applies a `KeepiqSecret` that maps field `key` of `db-password` to key `DB_PASSWORD` of target Secret `shop-db` +- **THEN** Kubernetes Secret `shop-db` MUST contain `DB_PASSWORD` with the decrypted value +- **AND** the `KeepiqSecret` MUST report condition `Ready=True` + +### Requirement: The application key stays in the cluster + +The operator MUST read the application private key only from the Kubernetes Secret named in `KeepiqConnection.privateKeySecretRef`, MUST NOT send it to any endpoint, and MUST check each envelope's certificate fingerprint against that key before decrypting. The operator MUST NOT write any decrypted value to its logs, resource status or events. + +#### Scenario: Wrong key is reported, not used + +- **GIVEN** a `KeepiqConnection` whose key Secret holds a key that does not match the application's certificate +- **WHEN** the operator reconciles a `KeepiqSecret` on that connection +- **THEN** the `KeepiqSecret` MUST report `Ready=False` with reason `FingerprintMismatch` +- **AND** the target Secret MUST NOT change + +### Requirement: Rotated values reach the cluster + +The operator MUST poll each item with `If-None-Match` at the resource's `refreshInterval` (default 60 seconds, minimum 10 seconds). When a value changed, it MUST update the target Secret and MUST patch a checksum annotation on each listed restart target so the workload restarts. When nothing changed, it MUST NOT write the target Secret. + +#### Scenario: Rotation restarts the workload + +- **GIVEN** a `KeepiqSecret` for `db-password` with restart target Deployment `shop-api` +- **WHEN** a machine client writes a new value for `db-password` through `PUT /api/v1/app/secrets/{id}` +- **THEN** Kubernetes Secret `shop-db` MUST hold the new value within one refresh interval +- **AND** Deployment `shop-api` MUST roll out new pods + +### Requirement: Errors are visible on the resource + +The operator MUST report `Ready=False` with a reason and an event for an unknown name (404), an ambiguous name (409, listing the candidates' ids and folder paths), a refused token and a fingerprint mismatch, and MUST leave the target Secret unchanged in each case. + +#### Scenario: Ambiguous name + +- **GIVEN** two secrets named `api-token` in the application vault +- **WHEN** a `KeepiqSecret` asks for `api-token` without a folder +- **THEN** the resource MUST report `Ready=False` with reason `AmbiguousName` +- **AND** an event MUST list both candidates' ids and folder paths + +### Requirement: Leases are honoured when advertised + +When the discovery document advertises lease support, the operator MUST record the `Doriath-Lease-Id` and `Doriath-Lease-Expires` headers in the resource status, MUST renew the lease through `POST /api/v1/app/leases/{id}/renew` before it expires, and MUST refetch after a refused renewal. Against an instance without lease support it MUST work unchanged. + +#### Scenario: Lease is renewed before expiry + +- **GIVEN** an instance that advertises leases and a lease that expires in two minutes +- **WHEN** the operator's next loop runs +- **THEN** the operator MUST renew the lease and record the new expiry in status + +### Requirement: Pods can receive values without a Kubernetes Secret + +The Helm chart MUST document a pod recipe in which an init container copies the static `keepiq` CLI from the published CLI image into the pod, and the container starts its original command through `keepiq ci run `, which takes that command after its `--` separator, so values exist only in the process environment. + +#### Scenario: Recipe pod reads its password from the environment + +- **GIVEN** a pod built from the documented recipe for secret `db-password` +- **WHEN** the pod starts +- **THEN** the main process MUST see `KEEPIQ_DB_PASSWORD` in its environment +- **AND** no Kubernetes Secret in the namespace MUST contain the value + +### Requirement: The operator is released from this repository + +A tag `k8s-v` MUST publish a signed multi-arch container image and a Helm chart built from `integrations/kubernetes/`. Pull requests touching that directory MUST run its unit, envtest and kind tests. + +#### Scenario: Tagged release publishes image and chart + +- **GIVEN** a maintainer pushes tag `k8s-v0.1.0` +- **WHEN** the release workflow finishes +- **THEN** image `ghcr.io/conductionnl/keepiq-operator:0.1.0` and chart version `0.1.0` MUST be published diff --git a/openspec/changes/apps-kubernetes-injection/tasks.md b/openspec/changes/apps-kubernetes-injection/tasks.md new file mode 100644 index 000000000..b7ba83158 --- /dev/null +++ b/openspec/changes/apps-kubernetes-injection/tasks.md @@ -0,0 +1,27 @@ +## 1. Module and resources + +- [ ] 1.1 Create the Go module `integrations/kubernetes/` on controller-runtime, importing `sdk/go/`; if `sdk/go/` does not exist yet, extract it from `cli/internal/client` and `cli/internal/crypto` first. Verify with `go vet ./...` and `go test ./...` in both modules. +- [ ] 1.2 Define the `KeepiqConnection` and `KeepiqSecret` CRDs with validation (refresh minimum, field syntax). Verify with envtest tests that invalid resources are rejected. + +## 2. Reconcile + +- [ ] 2.1 Implement the reconcile loop: token cache, by-name fetch with ETag, fingerprint check, in-memory decrypt, target Secret with owner reference, requeue. Verify with envtest tests against an httptest stub serving envelopes from `sdk/testdata/`. +- [ ] 2.2 Set `Ready` conditions and events for 404, 409 with candidates, token refusal and fingerprint mismatch, never including a value. Verify with envtest tests that assert status and events contain no plaintext. +- [ ] 2.3 Patch a checksum annotation on each restart target when a value changes. Verify with an envtest test that the Deployment template annotation changes once per rotation. +- [ ] 2.4 Renew leases before expiry and refetch after a refused renewal. Verify with envtest tests against a stub that advertises leases and one that does not. + +## 3. Distribution + +- [ ] 3.1 Add the Helm chart with namespaced RBAC by default and a cluster-wide option. Verify with `helm lint` and `helm template` snapshot tests in CI. +- [ ] 3.2 Document the no-Secret recipe (init container with the CLI image, `keepiq ci run` wrapper) in the chart README and the docs site. Verify with a kind test that starts a pod with the recipe and reads the value from the process environment. +- [ ] 3.3 Add `.github/workflows/integrations-kubernetes.yml`: tests on pull requests, kind test, signed multi-arch image and OCI chart on `k8s-v*` tags. Verify with a dry run of the workflow on a pull request. +- [ ] 3.4 Add a live test gated by `KEEPIQ_LIVE_URL` that syncs one secret from a real instance. Verify manually against the dev instance. + +## Acceptance criteria + +- A `KeepiqSecret` naming an application secret produces a Kubernetes Secret with the decrypted value within one refresh interval. +- Changing the value in Keepiq updates the Kubernetes Secret within one refresh interval and, when configured, restarts the named Deployment. +- No request from the operator to Keepiq carries plaintext, and no status, event or log line carries a value. +- A 409 for an ambiguous name leaves the target Secret unchanged and shows the candidates in an event. +- The recipe pod reads the value from its process environment and no Kubernetes Secret holds it. +- A tagged release publishes a signed image and a Helm chart. diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/.openspec.yaml b/openspec/changes/apps-secret-sync-and-rotation-runner/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/design.md b/openspec/changes/apps-secret-sync-and-rotation-runner/design.md new file mode 100644 index 000000000..b3719376f --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/design.md @@ -0,0 +1,90 @@ +# Design: secret rotation and sync runner + +## Context + +Read at development `4c214a9d`. + +- Rotation today is reminders and a proof check: `openspec/specs/rotation-expiry-policies/spec.md` ("Proven mark-rotated flow") closes a flag only when `key_updated_at` advanced; `lib/Controller/RotationController.php` and `src/store/modules/rotation.js` serve it to users. Nothing changes a credential at its target. +- The machine API: token exchange `appinfo/routes.php:299`; list, by-id, by-name, create and update at `:304` to `:309`. `lib/Controller/ApplicationSecretsController.php:329` `update()` replaces ciphertext through `lib/Service/SecretService.php` `updateByApplication()`, which scopes to the application's own vault, advances `key_updated_at` when the key changes, snapshots the previous version and audits `SECRET_UPDATED` with the application as actor. It has no precondition: a write based on a stale read silently wins. +- `lib/Service/MachineSecretEnvelopeService.php:129` `serialize()` returns `format`, `secret` (id, name, url, folderPath, type, createdAt, updatedAt, keyUpdatedAt), `encryption` and `ciphertext`. `expiresAt` exists on the entity (`lib/Db/Secret.php:173`) but not in the envelope. +- `lib/Service/MachineSecretResponseService.php:98` handles `If-None-Match` for reads. +- The machine API has no delete by design (`openspec/specs/secret-store-api/spec.md`, "Application Write-Back"). +- `cli/` is a stdlib-only single binary (`cli/README.md`); `sdk/go/` is introduced by change `apps-client-libraries-and-ci`. +- `git grep -i 'aws|azure|key vault' lib src` finds nothing. + +## Goals / Non-Goals + +**Goals:** + +- A credential in an application vault is rotated at its target on a schedule, with proof that the new value works before Keepiq records it. +- Secrets in an application vault reach AWS Secrets Manager, Azure Key Vault and GitHub Actions secrets and stay in sync. +- The Keepiq server never sees plaintext and needs no key. + +**Non-Goals:** + +- Rotating secrets in user vaults. Only an application's own key can decrypt its vault; a user vault needs the user's master password, which never leaves their client. +- Giving humans a readable copy of a rotated value. Application vault values are readable only by the application (write without read). A team that needs a human copy points an `exec` destination at its own process. +- Deleting at the destination when a Keepiq secret is deleted. The machine API cannot see deletions; the runner reports destination entries it no longer finds in Keepiq. +- More connectors in v1 (LDAP, Active Directory, GCP, Vercel). The `exec` hook covers them until a native connector follows. + +## Decisions + +### D1: A separate runner binary, not a CLI mode + +`integrations/runner/` is a Go module on `sdk/go/`, producing `keepiq-runner` and image `ghcr.io/conductionnl/keepiq-runner`. It runs as a daemon or once (`keepiq-runner run --once`) for cron and Kubernetes CronJobs. + +Alternative considered: a `keepiq runner` mode in the CLI. Rejected: database drivers and cloud SDKs would end the CLI's stdlib-only, dependency-free build that its README and spec promise. + +### D2: Configuration names Keepiq secrets, never values + +`runner.yaml` holds the Keepiq URL, application id and private key file, then `rotations` (secret name and folder, connector, target address, the Keepiq secret holding the admin credential for the target, cron schedule, `followExpiry`, generator length and character classes) and `syncs` (secret names, destination, destination address, the Keepiq secret holding destination credentials, or ambient cloud identity). Every credential the runner needs is itself a secret in the same application vault. + +### D3: Rotation is prove-then-record, with a journal + +For one rotation: + +1. Read the secret and its ETag. +2. Generate the new value with `crypto/rand`. +3. Append to the local journal the new value encrypted to the application's own public key, plus the secret id and ETag. The journal holds ciphertext only. +4. Set the new value at the target with the admin credential (`ALTER ROLE ... PASSWORD` for PostgreSQL, `ALTER USER ... IDENTIFIED BY` for MySQL, or the `exec` hook with current and new values on stdin as JSON). +5. Log in to the target with the new value. +6. `PUT /api/v1/app/secrets/{id}` with the new ciphertext and `If-Match` set to the ETag from step 1. +7. Remove the journal entry. + +If step 5 fails, the runner sets the old value back at the target and leaves Keepiq unchanged. If step 6 fails or the process dies after step 4, the next start decrypts the journal entry and retries the write-back. On 412 the value changed in Keepiq during the rotation; the runner sets the old value back at the target and reports a conflict. + +Alternative considered: write the new value to Keepiq first, then change the target. Rejected: until the target accepts it, every consumer polling Keepiq would read a password that does not work yet. + +### D4: When a rotation is due + +A rotation runs when its cron schedule fires, or, with `followExpiry`, when the envelope's `expiresAt` is within the configured lead time. Because the write-back advances `key_updated_at`, any open rotation flag on that secret meets the existing proof rule. + +### D5: Sync polls `updated_since` and pushes on change + +Every sync interval (default 60 seconds) the runner lists the application's secrets with `updated_since`, fetches each changed secret in a sync set, decrypts it, and pushes it: `PutSecretValue` (creating on first sync) for AWS Secrets Manager, `SetSecret` for Azure Key Vault, and the GitHub REST secrets API for repository, environment or organisation secrets, encrypted with the repository public key as a libsodium sealed box as GitHub requires. The state directory keeps, per destination entry, the ETag last pushed, never a value. A failed push is retried with backoff and never blocks other entries. + +### D6: Two additive changes to the machine API + +`PUT /api/v1/app/secrets/{id}` accepts `If-Match`; when it does not match the current strong ETag the server answers 412 and changes nothing. A write without `If-Match` behaves as today. The envelope's `secret` block gains `expiresAt` (ISO 8601 or null). Both are additive to `doriath-machine-secret-v1`, so existing consumers keep working, and the discovery document advertises `conditionalWrite: true` and `expiresAt: true`. + +## Security and zero-knowledge + +- The server never sees plaintext or the application private key. Rotation writes back ciphertext produced in the runner; sync reads ciphertext and decrypts in the runner. +- Encrypted: the write-back value (RSA to the application key), the journal entries (same), everything on the wire to Keepiq. Plain, outside Keepiq: the value in the runner's memory, at the target, and at the destination, which is the purpose of rotation and sync. +- Plain in the state directory: secret ids, destination names and ETags only. +- Every rotation is audited on the server as `SECRET_UPDATED` by the application, with the previous version kept by version history, so an administrator can see and roll back a rotation. +- The runner's host holds the application key; the docs recommend one application per runner with only the secrets it rotates or syncs. + +## Risks / Trade-offs + +- A bug in a connector can lock a service out. The prove-then-record order and the automatic set-back on a failed login keep the old credential working until the new one is proven. +- Sync copies plaintext into another system with its own access model. That is the request; the docs state it, and the runner pushes only secrets named in a sync set. +- A cloud SDK per destination grows the runner. They stay in `integrations/runner/go.mod`, away from the CLI and the libraries. + +## Seed data + +None in the app. The runner's tests start PostgreSQL and MySQL containers and a stub Keepiq server serving the shared vectors from `sdk/testdata/`; cloud destinations are tested against local emulators (LocalStack for AWS, an httptest stub for Azure and GitHub). + +## Migration + +None. `expires_at` already exists; the server changes are code only. `` in `appinfo/info.xml` does not need a bump for schema reasons. diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/proposal.md b/openspec/changes/apps-secret-sync-and-rotation-runner/proposal.md new file mode 100644 index 000000000..e3c62781e --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/proposal.md @@ -0,0 +1,55 @@ +--- +kind: code +--- + +# Secret rotation and sync runner + +## Why + +Keepiq reminds people to rotate and lets them mark a secret rotated, but it never changes a password at the database or service itself, and it never pushes a secret to a cloud secret store. The server cannot do either: it never sees plaintext. This change puts both jobs in a runner that holds an application's private key. + +| Row | Capability | What keepiq does today | +|---|---|---| +| apps-20 | Rotate a database or service password automatically | keepiq flags stale/expiring secrets and lets a user mark one rotated, but it never rotates a password at the destination service itself the way Vault/1Password-style rotation connectors do. | +| apps-25 | Push secrets out to cloud secret stores such as AWS Secrets Manager, Azure Key Vault or GitHub and keep them in sync | No push of secrets to cloud secret stores. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +The matrix recorded apps-20 as built; the decision corrected that: the evidence shows only reminders and a manual mark-rotated flow, so the state before this change is none. + +### Demand + +No demand row. + +### Competitors rated yes + +- Keeper (apps-20): "https://docs.keeper.io/keeperpam/privileged-access-manager/password-rotation/rotation-overview : scheduled rotation of database, AD, cloud and machine credentials via the Keeper Gateway (KeeperPAM / rotation add-on)" +- HashiCorp Vault (apps-20): "hashicorp/vault@v2.1.1 builtin/logical/database/path_creds_create.go:57 static-creds/; builtin/logical/database/path_rotate_credentials.go:21 rotate-root, :48 rotate-role Note: Static roles rotate a database user's password on a period or schedule, with manual rotate endpoints." +- Keeper (apps-25): "https://docs.keeper.io/keeperpam/privileged-access-manager/universal-secrets-sync : 'Synchronize Shared Secrets to Cloud Secret Management Services'; Universal Secrets Sync automatically pushes shared secrets to cloud secret stores (KeeperPAM)." +- HashiCorp Vault (apps-25): "hashicorp/vault@v2.1.1 ui/lib/sync/addon/routes.js:10 destinations and sync routes; ui/lib/sync/addon/utils/constants.ts:12 aws-sm, azure-kv, gcp-sm, gh, vercel-project; ... | docs: https://developer.hashicorp.com/vault/docs/sync ..." + +## What Changes + +- A runner, `keepiq-runner`, in `integrations/runner/`, released as a static binary and a container image. It authenticates as one Keepiq application and works only on that application's vault through the machine API under `/api/v1/app/*`. +- **Rotation**: on a cron schedule or when a secret nears its expiry date, the runner generates a new password locally, sets it at the target (PostgreSQL, MySQL, or any system through an `exec` hook), logs in with it to prove it works, and writes it back to Keepiq encrypted to the application's own key. +- A local recovery journal holds the new value encrypted to the application's key between the target change and the write-back, so a crash never loses the only copy. +- **Sync**: the runner polls `updated_since`, decrypts changed secrets, and pushes them to AWS Secrets Manager, Azure Key Vault, GitHub Actions secrets or an `exec` hook. +- Two additive server changes to the machine API: `PUT /api/v1/app/secrets/{id}` honours `If-Match` and answers 412 on a mismatch, and the envelope carries `expiresAt`. + +## Capabilities + +### New Capabilities + +- `secret-rotation-runner`: a runner outside the server that rotates credentials at their target and pushes secrets to cloud secret stores, decrypting only with an application's own key. + +### Modified Capabilities + +- `secret-store-api`: conditional write-back with `If-Match`, and the secret's expiry date in the machine envelope. + +## Impact + +- **Backend**: `ApplicationSecretsController::update()` and `SecretService::updateByApplication()` check `If-Match`; `MachineSecretEnvelopeService::serialize()` adds `expiresAt`; the discovery document advertises both. +- **Frontend**: none. +- **Database**: none. `expires_at` already exists on `keepiq_secrets`. +- **Security**: the server keeps receiving and returning ciphertext only. Plaintext exists in the runner's memory, at the rotation target and at the sync destination, all outside the Keepiq server and under the application owner's control. +- **Cross-app**: the runner builds on `sdk/go/` from change `apps-client-libraries-and-ci`. OpenConnector and other machine consumers see rotated values through the existing ETag and `updated_since` polling. diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md b/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md new file mode 100644 index 000000000..857102af4 --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: Runner works on one application's vault outside the server + +The project MUST ship `keepiq-runner`, built from `integrations/runner/`, that authenticates as one Keepiq application with that application's private key and uses only the machine API under `/api/v1/app/*`. It MUST decrypt and encrypt only in its own process, MUST NOT send the private key or any plaintext to Keepiq, and MUST NOT write any plaintext value to its logs or state directory. + +#### Scenario: Runner starts with an application key + +- **GIVEN** an approved application `ops-runner` and a `runner.yaml` pointing at its private key file +- **WHEN** an operator starts `keepiq-runner run --once` +- **THEN** the runner MUST obtain a bearer token through `POST /api/v1/token` +- **AND** no request body or log line MUST contain the private key or a secret value + +### Requirement: Rotation proves the new value before Keepiq records it + +For each configured rotation, the runner MUST generate a new value locally, record it in a local journal encrypted to the application's own key, set it at the target, log in to the target with it, and only then write it back through `PUT /api/v1/app/secrets/{id}` with `If-Match` set to the ETag it read. When the login fails, the runner MUST set the old value back at the target and MUST leave Keepiq unchanged. + +#### Scenario: Weekly database password rotation + +- **GIVEN** application `ops-runner` owns secret `pg-app-password` and a rotation for it with the `postgres` connector and schedule `0 3 * * 0` +- **WHEN** the schedule fires +- **THEN** the PostgreSQL role MUST accept the new password and refuse the old one +- **AND** `GET /api/v1/app/secrets/by-name/pg-app-password` MUST return an envelope whose decrypted value is the new password + +#### Scenario: Failed proof keeps the old credential + +- **GIVEN** a rotation whose target accepts the change but refuses the login with the new value +- **WHEN** the rotation runs +- **THEN** the target MUST be set back to the old value +- **AND** the secret in Keepiq MUST keep its previous ciphertext and ETag + +### Requirement: A crashed rotation is completed from the journal + +When the runner starts and finds a journal entry, it MUST decrypt it with the application key and retry the write-back. After a successful write-back it MUST remove the entry. The journal MUST hold ciphertext only. + +#### Scenario: Power loss after the target changed + +- **GIVEN** the runner set a new value at the target and stopped before the write-back +- **WHEN** the runner starts again +- **THEN** it MUST write the journalled value back to Keepiq and remove the journal entry + +### Requirement: Concurrent changes are never overwritten + +When the conditional write-back answers 412, the runner MUST set the old value back at the target, MUST NOT retry the write, and MUST report a conflict naming the secret. + +#### Scenario: Human changed the secret during rotation + +- **GIVEN** a rotation read `pg-app-password` with ETag `A` and another client updated it to ETag `B` +- **WHEN** the runner writes back with `If-Match: A` +- **THEN** the server MUST answer 412 +- **AND** the runner MUST restore the old value at the target and log a conflict for `pg-app-password` + +### Requirement: Rotations run on schedule or ahead of expiry + +A rotation MUST run when its cron schedule fires, and, when `followExpiry` is set, when the envelope's `expiresAt` falls within the configured lead time. + +#### Scenario: Expiry triggers a rotation + +- **GIVEN** a rotation with `followExpiry` and a lead time of 7 days, and a secret whose `expiresAt` is in 5 days +- **WHEN** the runner checks its rotations +- **THEN** it MUST rotate that secret + +### Requirement: Sync pushes changed secrets to cloud secret stores + +For each configured sync set, the runner MUST poll `GET /api/v1/app/secrets?updated_since=`, decrypt each changed secret in the set, and push it to the destination: AWS Secrets Manager, Azure Key Vault, GitHub Actions secrets (encrypted as a sealed box with the repository public key) or an `exec` hook. It MUST keep per destination entry only the ETag last pushed, MUST NOT push an unchanged secret again, and MUST retry a failed push with backoff without blocking other entries. + +#### Scenario: Rotated key reaches AWS + +- **GIVEN** a sync set with secret `stripe-key` and destination `aws-secrets-manager` with prefix `prod/` +- **WHEN** `stripe-key` is updated in the application vault +- **THEN** AWS Secrets Manager secret `prod/stripe-key` MUST hold the new value within one sync interval + +#### Scenario: GitHub secret is sealed for the repository + +- **GIVEN** a sync set with destination `github-actions` for repository `example/app` +- **WHEN** the runner pushes `deploy-token` +- **THEN** the request to GitHub MUST carry the value encrypted with the repository public key diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md b/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md new file mode 100644 index 000000000..68c0ac83e --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: Conditional machine write-back + +`PUT /api/v1/app/secrets/{id}` MUST accept an `If-Match` header. When the header is present and does not equal the secret's current strong ETag, the server MUST answer 412 Precondition Failed and MUST NOT change the secret. When the header is absent, the endpoint MUST behave as before. The discovery document MUST advertise `conditionalWrite: true`. + +#### Scenario: Stale write is refused + +- **GIVEN** an application read secret `pg-app-password` with ETag `A`, and the secret was later updated to ETag `B` +- **WHEN** the application calls `PUT /api/v1/app/secrets/{id}` with `If-Match: A` +- **THEN** the response MUST be 412 +- **AND** the stored ciphertext MUST still be the one behind ETag `B` + +#### Scenario: Matching write succeeds + +- **GIVEN** an application holds the current ETag of its secret +- **WHEN** it calls `PUT /api/v1/app/secrets/{id}` with that ETag in `If-Match` and new ciphertext +- **THEN** the ciphertext MUST be replaced and a new ETag returned + +### Requirement: Expiry date in the machine envelope + +The `secret` block of the machine envelope MUST include `expiresAt` as an ISO 8601 timestamp, or null when the secret has no expiry. Adding it MUST NOT change any other envelope field. The discovery document MUST advertise `expiresAt: true`. + +#### Scenario: Consumer reads the expiry date + +- **GIVEN** an application secret with an expiry date of 1 December 2026 +- **WHEN** the application fetches it through `GET /api/v1/app/secrets/{id}` +- **THEN** the envelope's `secret.expiresAt` MUST be `2026-12-01T00:00:00+00:00` diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/tasks.md b/openspec/changes/apps-secret-sync-and-rotation-runner/tasks.md new file mode 100644 index 000000000..a51c06ecc --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/tasks.md @@ -0,0 +1,32 @@ +## 1. Machine API additions + +- [ ] 1.1 Honour `If-Match` in `ApplicationSecretsController::update()` and answer 412 on a mismatch without writing. Verify with PHPUnit tests in `tests/Unit/Controller/ApplicationSecretsControllerTest.php` for match, mismatch and absent header. +- [ ] 1.2 Add `expiresAt` to the envelope's `secret` block and advertise `conditionalWrite` and `expiresAt` in the discovery document. Verify with PHPUnit tests on `MachineSecretEnvelopeService` and `DiscoveryController`, and a new assertion in `tests/integration/machine-secret-api.postman_collection.json`. + +## 2. Runner core + +- [ ] 2.1 Create module `integrations/runner/` on `sdk/go/` with config loading, daemon and `run --once` modes, and a structured log that never contains a value. Verify with Go tests for config validation and a log test that greps for the test value. +- [ ] 2.2 Implement the rotation procedure with generator, journal (ciphertext only), set, prove, conditional write-back, set-back on failed login, and recovery from the journal. Verify with Go tests that kill the process after the target change and assert the next start completes the write-back. +- [ ] 2.3 Schedule rotations by cron and by `expiresAt` lead time. Verify with Go tests using a fake clock. + +## 3. Connectors and destinations + +- [ ] 3.1 Add the `postgres` and `mysql` rotation connectors. Verify with Go integration tests against PostgreSQL and MySQL containers that log in with the new password and fail with the old one. +- [ ] 3.2 Add the `exec` connector and `exec` destination (JSON on stdin, exit code as result). Verify with Go tests using a script fixture. +- [ ] 3.3 Implement sync with `updated_since` polling, per-entry ETag state and backoff. Verify with Go tests against the stub server that a changed secret is pushed once and an unchanged one never. +- [ ] 3.4 Add the `aws-secrets-manager`, `azure-key-vault` and `github-actions` destinations. Verify with Go tests against LocalStack and httptest stubs, including the sealed-box encryption for GitHub. + +## 4. Release + +- [ ] 4.1 Add `.github/workflows/integrations-runner.yml`: tests on pull requests, static binaries and a signed image on `runner-v*` tags. Verify with a dry run on a pull request. +- [ ] 4.2 Document setup, the application-per-runner advice and every connector on the docs site. Verify with the docs build in `docs/`. +- [ ] 4.3 Rotate a real credential end to end. Verify manually on the dev instance: an application owns `pg-app-password`, the runner rotates it at a local PostgreSQL, and `keepiq ci fetch pg-app-password` returns a value that logs in. + +## Acceptance criteria + +- A scheduled rotation changes the PostgreSQL password, proves the new one by logging in, and only then stores it in Keepiq. +- If the new password does not log in, the old one keeps working and Keepiq is unchanged. +- A crash between the target change and the write-back is repaired on the next start. +- A concurrent change in Keepiq makes the write-back fail with 412 and the runner restores the old target value. +- A changed secret in a sync set reaches AWS Secrets Manager, Azure Key Vault or GitHub within one sync interval. +- No request from the runner to Keepiq, and no log line or state file, contains a plaintext value. diff --git a/openspec/changes/apps-terraform-provider/.openspec.yaml b/openspec/changes/apps-terraform-provider/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/apps-terraform-provider/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/apps-terraform-provider/design.md b/openspec/changes/apps-terraform-provider/design.md new file mode 100644 index 000000000..684c93128 --- /dev/null +++ b/openspec/changes/apps-terraform-provider/design.md @@ -0,0 +1,80 @@ +# Design: Terraform provider + +## Context + +Read at development `4c214a9d`. + +- The machine API covers what a provider needs for secrets: token exchange (`appinfo/routes.php:299`), list, by-id, by-name, create and update (`:304` to `:309`), ETag reads (`lib/Service/MachineSecretResponseService.php:98`). There is no delete route on purpose (`openspec/specs/secret-store-api/spec.md`, "Application Write-Back", scenario "Machine deletion refused"). +- `lib/Controller/ApplicationSecretsController.php:283` `create()` and `:329` `update()` accept fields already encrypted to the application's own certificate; the server validates shape only. +- Applications are registered and approved through session routes (`appinfo/routes.php:271` to `:286`); change `admin-public-api` adds `/api/v1/admin/applications` for scripts. +- `cli/internal/crypto/crypto.go` holds the Go crypto recipe; change `apps-client-libraries-and-ci` moves it to `sdk/go/` and adds encryption. +- `grep -rli terraform` over the repository finds nothing. +- Terraform stores every resource and data source attribute in plan and state files. Terraform 1.10 added ephemeral resources, which are never stored; Terraform 1.11 added write-only arguments, which are sent to the provider and never stored. OpenTofu added the same two features in its own releases; the provider docs state the tested minimum OpenTofu version. + +## Goals / Non-Goals + +**Goals:** + +- Declare application secrets and applications in Terraform or OpenTofu. +- Keep every secret value out of plan and state. +- Publish in the registries where Terraform and OpenTofu users look. + +**Non-Goals:** + +- User vault secrets. The provider is a machine client; user vaults need a master password that never leaves the user's client. +- A data source that returns a value. Data source attributes are written to state; the ephemeral resource replaces it. +- Hard deletion of secrets (see D4). +- Terraform versions before 1.11. Older versions cannot keep a written value out of state; the provider refuses to plan a `value_wo` there with a clear message. + +## Decisions + +### D1: Source here, published through a mirror repository + +The provider is a Go module at `integrations/terraform-provider-keepiq/` using `terraform-plugin-framework` and `sdk/go/`. The Terraform Registry only indexes public repositories named `terraform-provider-` with GPG-signed releases. A workflow on tag `tf-v*` pushes the module to `ConductionNL/terraform-provider-keepiq`, where GoReleaser builds, signs and publishes the release; the registries pick it up from there. Creating that repository and its deploy key is a one-time organisation admin step. + +Alternative considered: develop the provider only in its own repository. Rejected: the crypto recipe and its vectors live here, and the provider must fail CI in the same pull request that changes them. + +### D2: Provider configuration + +`url`, `application_id`, `private_key` (sensitive; defaults to `KEEPIQ_APP_KEY` or the file in `KEEPIQ_APP_KEY_FILE`) for secrets, and optionally `admin_username` and `admin_app_password` (sensitive; defaults to `KEEPIQ_ADMIN_USER` and `KEEPIQ_ADMIN_APP_PASSWORD`) for application resources. The provider discovers the instance and caches the bearer token for the run. + +### D3: Values only through ephemeral reads and write-only arguments + +- `ephemeral "keepiq_secret"` takes `name` and optional `folder`, or `id`, and returns `value`, `login` and `additional_fields`, decrypted in the provider. Terraform never persists them. +- `resource "keepiq_secret"` takes `name`, `folder`, `url`, and the write-only `value_wo`, `login_wo` and `additional_fields_wo`, plus `value_wo_version`. The provider encrypts the write-only values to the public half of the application key, after checking it against the vault's certificate fingerprint, and creates or updates the secret. A change of `value_wo_version` triggers an update. State holds `id`, metadata, `etag` and `key_updated_at`. +- Refresh reads the envelope. When `key_updated_at` moved outside Terraform (a rotation by the runner, for example), the provider records the new timestamp and reports no diff, because Terraform cannot know the value; `value_wo_version` stays the only trigger for a Terraform write. +- `data "keepiq_secret_metadata"` returns id, timestamps, `expires_at` and fingerprint, never a value. + +Alternative considered: a classic `sensitive` value attribute. Rejected: `sensitive` hides a value in output but still writes it to state in plain form. + +### D4: Destroy removes from state and warns + +The machine API refuses deletion by design: a leaked five-minute bearer token must not be able to destroy credentials. On destroy, `keepiq_secret` is removed from state and the provider emits a warning naming the secret and saying that an administrator deletes it in Keepiq. Import (`terraform import keepiq_secret.x `) adopts an existing secret. + +### D5: Applications through the admin API + +`keepiq_application` takes `name`, `description` and `csr_pem`, registers the application, approves it through `POST /api/v1/admin/applications/{id}/approve`, and exports `id` and `certificate_pem`. The private key stays with whoever made the CSR; the docs warn that generating it with `tls_private_key` stores it in state. Deleting an application deletes its vault, so destroy requires `allow_vault_deletion = true` on the resource and otherwise fails with an explanation. `keepiq_application_lease_policy` manages the lease TTL policy through the admin API. + +### D6: Tests and docs + +Unit tests run the provider against an httptest stub that serves the shared vectors from `sdk/testdata/`, with `terraform-plugin-testing`, and assert that no plan or state file contains the test value. Acceptance tests (`TF_ACC=1`) run against a real instance when `KEEPIQ_LIVE_URL` is set. Documentation is generated with `tfplugindocs` into the module's `docs/` folder, as the registry requires. + +## Security and zero-knowledge + +- The server never sees plaintext: the provider encrypts before `POST` or `PUT` and decrypts after reads, in its own process. +- Not stored anywhere by Terraform: secret values (ephemeral outputs and write-only arguments). Stored plain in state: ids, names, folders, URLs, timestamps, ETags, the application certificate. The private key and admin app password are provider arguments marked sensitive and taken from the environment by default. +- The test suite fails when a plan or state file contains the test value, so a regression that leaks a value into state cannot merge. + +## Risks / Trade-offs + +- Terraform 1.11, or an OpenTofu release with write-only arguments, is the minimum for managed values. Terraform 1.10 can still use the ephemeral read. +- Destroy does not delete the secret in Keepiq. The warning names it; the alternative would give a machine token deletion rights the API refuses on purpose. +- The mirror repository adds a release hop. The workflow is the only writer, and the mirror's README says changes go to this repository. + +## Seed data + +None in the app. The acceptance tests register their own application through the admin API on the test instance. + +## Migration + +None. No server change, so no table, column or `` bump. diff --git a/openspec/changes/apps-terraform-provider/proposal.md b/openspec/changes/apps-terraform-provider/proposal.md new file mode 100644 index 000000000..3e02cc431 --- /dev/null +++ b/openspec/changes/apps-terraform-provider/proposal.md @@ -0,0 +1,55 @@ +--- +kind: code +--- + +# Terraform provider + +## Why + +Teams that manage infrastructure as code cannot declare Keepiq secrets or applications in Terraform or OpenTofu. They copy values by hand or script the machine API. The usual provider pattern also puts secret values in Terraform state, which a zero-knowledge vault must avoid. + +| Row | Capability | What keepiq does today | +|---|---|---| +| apps-22 | Manage secrets as code with a Terraform provider | No Terraform provider exists for managing keepiq secrets/applications as code. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts:101 Terraform Provider (registry.terraform.io/providers/bitwarden/bitwarden-secrets) Note: Terraform provider listed in product; its code is in a separate repo. Docs rating kept." +- 1Password: "https://developer.1password.com/docs/terraform : reference, create or update items as Terraform resources" +- Keeper: "https://docs.keeper.io/keeperpam/secrets-manager/integrations/terraform : Terraform Provider for Keeper Secrets Manager" +- HashiCorp Vault: "hashicorp/vault@v2.1.1 vault/logical_system_paths.go:2899 OpenAPI spec the provider tooling builds on | docs: https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2 (Terraform provider is the separate hashicorp/terraform-provider-vault repo) ..." + +## What Changes + +- A Terraform and OpenTofu provider `keepiq` in `integrations/terraform-provider-keepiq/`, built on the plugin framework and on `sdk/go/`. +- An ephemeral resource `keepiq_secret` that reads and decrypts an application secret for one run and never writes it to plan or state. +- A resource `keepiq_secret` that manages a secret in the application's vault. Its value is a write-only argument (`value_wo`, with `value_wo_version`), encrypted by the provider to the application's key; state keeps metadata only. +- A data source `keepiq_secret_metadata` for id, timestamps, expiry and fingerprint, without the value. +- Resources `keepiq_application` and `keepiq_application_lease_policy` that register, approve and configure applications through the public admin API. +- `terraform destroy` on a `keepiq_secret` removes it from state and warns, because the machine API has no delete by design. +- Releases signed and published to the Terraform and OpenTofu registries through a mirror repository named `terraform-provider-keepiq`. +- No change to the Keepiq server. + +## Capabilities + +### New Capabilities + +- `terraform-provider`: manage Keepiq application secrets and applications as code, with secret values kept out of Terraform plan and state. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: none. Secrets go through the machine API; applications through the admin API from change `admin-public-api`. +- **Frontend**: none. +- **Database**: none. +- **Security**: values are decrypted and encrypted only in the provider process. Ephemeral resources and write-only arguments keep them out of plan and state files. The application private key and the admin app password are sensitive provider arguments, read from the environment by default. +- **Cross-app**: depends on `sdk/go/` (change `apps-client-libraries-and-ci`) and on `/api/v1/admin/applications` (change `admin-public-api`). diff --git a/openspec/changes/apps-terraform-provider/specs/terraform-provider/spec.md b/openspec/changes/apps-terraform-provider/specs/terraform-provider/spec.md new file mode 100644 index 000000000..74639d3c9 --- /dev/null +++ b/openspec/changes/apps-terraform-provider/specs/terraform-provider/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: Secret values never enter Terraform plan or state + +The `keepiq` provider MUST decrypt and encrypt secret values only in its own process, and MUST expose secret values only through the ephemeral resource `keepiq_secret` and the write-only arguments of the resource `keepiq_secret`. No resource or data source attribute stored in plan or state MUST contain a secret value. + +#### Scenario: Ephemeral read feeds another provider + +- **GIVEN** application `infra` with secret `db-password` and a configuration that passes `ephemeral.keepiq_secret.db.value` to a database provider +- **WHEN** an engineer runs `terraform apply` +- **THEN** the database provider MUST receive the decrypted value +- **AND** neither the saved plan nor the state file MUST contain it + +### Requirement: Managed secrets use write-only values + +The resource `keepiq_secret` MUST accept `value_wo`, `login_wo` and `additional_fields_wo` as write-only arguments, MUST encrypt them to the application's key after checking the vault certificate fingerprint, and MUST create or update the secret through `POST /api/v1/app/secrets` or `PUT /api/v1/app/secrets/{id}`. An update of the value MUST happen only when `value_wo_version` changes. State MUST hold only id, metadata, ETag and `key_updated_at`. + +#### Scenario: Engineer rotates a value by bumping the version + +- **GIVEN** a `keepiq_secret` resource `api_token` with `value_wo_version = 1` +- **WHEN** the engineer sets a new `value_wo` and `value_wo_version = 2` and runs `terraform apply` +- **THEN** the application MUST decrypt the new value from its vault +- **AND** the state file MUST NOT contain the old or the new value + +### Requirement: Destroy leaves the secret in Keepiq + +Destroying a `keepiq_secret` resource MUST remove it from state only and MUST emit a warning naming the secret and stating that an administrator deletes it in Keepiq, because the machine API offers no deletion. + +#### Scenario: Destroy warns instead of deleting + +- **GIVEN** a managed `keepiq_secret` named `old-token` +- **WHEN** the engineer runs `terraform destroy` +- **THEN** the run MUST succeed with a warning naming `old-token` +- **AND** `old-token` MUST still exist in the application vault + +### Requirement: Metadata without values + +The data source `keepiq_secret_metadata` MUST return id, name, folder, timestamps, `expires_at` and certificate fingerprint for a secret, and MUST NOT offer a value attribute. + +#### Scenario: Plan reacts to an expiry date + +- **GIVEN** a data source `keepiq_secret_metadata` for `db-password` +- **WHEN** Terraform reads it +- **THEN** it MUST expose `expires_at` and `key_updated_at` +- **AND** it MUST expose no value, login or additional field + +### Requirement: Applications are managed through the admin API + +The resource `keepiq_application` MUST register an application from a CSR, approve it through `POST /api/v1/admin/applications/{id}/approve` with the configured admin app password, and export its id and certificate. Destroying it MUST fail unless `allow_vault_deletion` is true, because deleting an application deletes its vault. + +#### Scenario: Pipeline application declared in code + +- **GIVEN** a service account app password holding the "Applications and machine access" area and a CSR for `ci-runner` +- **WHEN** an engineer applies a `keepiq_application` resource for `ci-runner` +- **THEN** `ci-runner` MUST be approved in Keepiq +- **AND** the resource MUST export its certificate + +#### Scenario: Accidental destroy is refused + +- **GIVEN** a `keepiq_application` resource without `allow_vault_deletion` +- **WHEN** the engineer runs `terraform destroy` +- **THEN** the run MUST fail with a message that the application's vault would be deleted + +### Requirement: The provider is published to both registries + +A tag `tf-v` MUST publish a GPG-signed provider release that the Terraform Registry and the OpenTofu Registry serve as `conductionnl/keepiq`. + +#### Scenario: Engineer installs the provider + +- **GIVEN** release `tf-v0.1.0` is published +- **WHEN** an engineer runs `terraform init` with `source = "conductionnl/keepiq"` and version `0.1.0` +- **THEN** Terraform MUST download and verify the signed provider diff --git a/openspec/changes/apps-terraform-provider/tasks.md b/openspec/changes/apps-terraform-provider/tasks.md new file mode 100644 index 000000000..fa601ffef --- /dev/null +++ b/openspec/changes/apps-terraform-provider/tasks.md @@ -0,0 +1,26 @@ +## 1. Provider + +- [ ] 1.1 Create module `integrations/terraform-provider-keepiq/` on `terraform-plugin-framework` and `sdk/go/`, with the provider configuration and environment defaults. Verify with `go test ./...` and a provider schema test. +- [ ] 1.2 Add the ephemeral resource `keepiq_secret` (by name and folder, or id). Verify with a `terraform-plugin-testing` test against the stub that the value is usable in a run and absent from plan and state. +- [ ] 1.3 Add the resource `keepiq_secret` with write-only value arguments, `value_wo_version`, fingerprint check, refresh and import. Verify with tests that create, update on version change, import, and assert no state file contains the value. +- [ ] 1.4 Make destroy of `keepiq_secret` remove from state with a warning naming the secret. Verify with a test on the diagnostics. +- [ ] 1.5 Add the data source `keepiq_secret_metadata`. Verify with a test that it exposes no value attribute. +- [ ] 1.6 Refuse `value_wo` on Terraform versions without write-only support, with a clear diagnostic. Verify with a test that sets an older client capability. + +## 2. Applications + +- [ ] 2.1 Add `keepiq_application` (register from CSR, approve, `allow_vault_deletion` guard) and `keepiq_application_lease_policy` on the admin API. Verify with stub tests for create, approve and a refused destroy, and an acceptance test when `KEEPIQ_LIVE_URL` is set. + +## 3. Release and docs + +- [ ] 3.1 Generate docs with `tfplugindocs` and add examples for each resource. Verify with a CI check that the generated docs are current. +- [ ] 3.2 Add `.github/workflows/integrations-terraform.yml`: tests on pull requests, and on `tf-v*` tags a push to the mirror repository. Verify with a dry run on a pull request. +- [ ] 3.3 Ask an organisation admin to create `ConductionNL/terraform-provider-keepiq` with a deploy key and GoReleaser signing, and register it in the Terraform and OpenTofu registries. Verify manually that `terraform init` resolves `conductionnl/keepiq` after the first tag. + +## Acceptance criteria + +- A configuration using `ephemeral "keepiq_secret"` passes a Keepiq value to another provider, and neither the plan file nor the state file contains that value. +- A `keepiq_secret` resource with `value_wo` creates a secret the application can decrypt, and changing `value_wo_version` updates it. +- `terraform destroy` leaves the secret in Keepiq and prints a warning naming it. +- An application can be registered and approved from Terraform with a CSR, and cannot be destroyed without `allow_vault_deletion`. +- A tagged release is installable with `terraform init` and `tofu init`. diff --git a/openspec/changes/audit-siem-vendor-connectors/.openspec.yaml b/openspec/changes/audit-siem-vendor-connectors/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/audit-siem-vendor-connectors/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/audit-siem-vendor-connectors/design.md b/openspec/changes/audit-siem-vendor-connectors/design.md new file mode 100644 index 000000000..b79e4ca37 --- /dev/null +++ b/openspec/changes/audit-siem-vendor-connectors/design.md @@ -0,0 +1,93 @@ +# Design: SIEM vendor connectors + +## Context + +The SIEM export is built and specified in `openspec/specs/siem-audit-export/spec.md`. The code this change touches, at development `4c214a9d`: + +- `lib/Service/SiemTransport.php:81` `deliver()` is the single place the transport is chosen: `syslog` goes to `deliverSyslog()` (`:100`, RFC 5424 with RFC 6587 octet framing, PRI 134, the JSON payload as MSG) and everything else to `deliverWebhook()` (`:152`, HTTPS POST with an `X-Keepiq-Signature` HMAC header, the secret decrypted from `hmacSecretEnc` with `ICrypto` at `:156`). +- `lib/Service/SiemService.php:110` `buildPayload()` rebuilds each audit event through `AuditEventTypes::WHITELIST` and drops every `AuditEventTypes::FORBIDDEN_KEYS` entry (`lib/Event/Audit/AuditEventTypes.php:173`). The payload keys are `eventType`, `category`, `actorType`, `actorId`, `objectType`, `objectId`, `occurredAt` and `metadata`. +- `lib/Service/SiemService.php:237` `deliverOne()` drains one queued item per call; `lib/BackgroundJob/DeliverSiemEventsJob.php` runs the drain. +- `lib/Service/SiemSinkService.php:93` accepts only `syslog` or `webhook` as `type`, and `:102` requires `https://` for webhooks. +- `lib/Db/SiemSink.php:109` holds `hmacSecretEnc`; `jsonSerialize()` (`:265`) only reports `hasHmacSecret` (`:273`), never the value. +- `lib/Controller/SiemSinkController.php` gates every route in-body on `IGroupManager::isAdmin()` (the `adminUid()` helper near `:69`); routes are `appinfo/routes.php:203` to `:207`. +- `src/components/settings/SiemSection.vue:140` offers the type select with `['syslog', 'webhook']`. +- The table is `siem_sinks` in `lib/Migration/Version001000Date20260908000000.php:681` (`type` is `STRING(16)`). + +## Goals / Non-Goals + +**Goals:** + +- Three named connectors an administrator can pick: Splunk HEC, Microsoft Sentinel, and CEF over syslog. +- Every connector sends a mapping of the existing sanitized payload and nothing more. +- Connector credentials follow the webhook HMAC secret's rules: encrypted at rest, write-only, never logged. +- Receiving-side templates in the repository, so the Sentinel table and the Splunk sourcetype need no hand-built parser. + +**Non-Goals:** + +- Datadog, Elastic, Sumo Logic, CrowdStrike, Panther or Rapid7 presets. They accept the generic webhook or CEF today; a named preset for each is a later change if demand shows. +- Pulling events (a SIEM polling a Keepiq events API). This change stays push-only, like the existing export. +- Batching several events into one request. The queue drains one item per delivery, as today. +- Sentinel analytics rules, workbooks or Splunk dashboards. + +## Decisions + +### D1: Splunk and Sentinel are new transports; CEF is a format of syslog + +`splunk_hec` and `sentinel` speak their own wire protocols with their own authentication, so they are new values of `type` next to `syslog` and `webhook`. CEF is not a protocol; it is a message body that SIEMs expect on a syslog stream, so it is a new `format` column (`json` default, `cef`) that only a `syslog` sink may set. + +Alternative considered: one `preset` column that rewrites a webhook sink's URL and headers. Rejected: Sentinel needs an OAuth token exchange before each batch of posts, which a webhook preset cannot express, and a preset that silently changes transport behaviour is harder to test than an explicit transport. + +### D2: Splunk HTTP Event Collector + +The sink endpoint is the HEC URL (`https://:8088/services/collector/event`; `https://` required). Keepiq posts one event per request with the header `Authorization: Splunk ` and the body `{"time": , "host": "", "source": "keepiq", "sourcetype": "keepiq:audit", "index": "", "event": }`. Delivery succeeds on HTTP 200 with a response `code` of `0`; anything else is a transport failure that enters the existing retry and dead-letter path. `connectorOptions` holds the optional `index` and `sourcetype` override. + +Alternative considered: Splunk's raw endpoint (`/services/collector/raw`). Rejected: the event endpoint carries time and sourcetype explicitly, so no Splunk-side timestamp extraction is needed. + +### D3: Microsoft Sentinel through the Logs Ingestion API + +Keepiq uses the Azure Monitor Logs Ingestion API, not the HTTP Data Collector API that Microsoft is retiring. `connectorOptions` holds `tenantId`, `clientId`, the data collection endpoint URL, the data collection rule immutable id and the stream name (default `Custom-KeepiqAudit`), plus an `authorityHost` (default `https://login.microsoftonline.com`) for sovereign clouds. The client secret is the sink credential. + +Per drain run, the transport requests a token with the client-credentials grant and scope `https://monitor.azure.com//.default`, keeps it in the PHP process for that run only, and posts `[row]` to `/dataCollectionRules//streams/?api-version=2023-01-01` with `Authorization: Bearer `. HTTP 204 is success. A 401 clears the cached token and retries once in the same run. + +The row maps the payload one to one: `TimeGenerated` (from `occurredAt`), `EventType`, `Category`, `ActorType`, `ActorId`, `ObjectType`, `ObjectId` and `Metadata` (a dynamic column holding the whitelisted metadata object). + +Alternative considered: caching the token in Nextcloud's distributed cache across runs. Rejected: a bearer token is a credential, and a cache is not an encrypted store. One token request per drain run is cheap. + +### D4: CEF formatting + +A `cef` syslog sink sends `CEF:0|Conduction|Keepiq|||||` as the RFC 5424 MSG. Header fields escape `\` and `|`; extension values escape `\`, `=` and line breaks, as the CEF specification requires. Extensions: `rt` (event time in epoch milliseconds), `cat` (category), `act` (event type), `suser` (actor id when the actor is a user), `cs1Label=actorType cs1`, `cs2Label=objectType cs2`, `cs3Label=objectId cs3`, and `msg` (the whitelisted metadata as compact JSON). Severity comes from a fixed map keyed on category (for example `honey` 10, `suite` 8, `emergency` 7, `share` 5, everything else 3) kept next to the formatter and covered by a test. + +Alternative considered: LEEF for QRadar. Rejected for this change: QRadar parses CEF, so one format covers QRadar, ArcSight and Sentinel's CEF connector. LEEF can follow if a customer asks. + +### D5: Formatters are separate, pure classes + +Each output shape is a small pure class under `lib/Service/Siem/` (`JsonFormatter`, `CefFormatter`, `SplunkHecFormatter`, `SentinelRowFormatter`) that takes the `buildPayload()` array and returns a string or array. `SiemTransport::deliver()` picks the formatter and the transport. This keeps the no-secret-material rule testable in one place: a single test feeds every formatter a payload and asserts that no output value comes from anywhere but that payload and fixed vendor constants. + +### D6: Receiving-side templates live in `integrations/siem/` + +`integrations/siem/sentinel/keepiq-dcr.json` is an Azure Resource Manager template that creates the custom table `KeepiqAudit_CL` with the D3 columns and the data collection rule with stream `Custom-KeepiqAudit`. `integrations/siem/splunk/props.conf` defines the `keepiq:audit` sourcetype (`KV_MODE = json`, time taken from the HEC envelope). Both are copied into place by the administrator; neither is executed by Keepiq. A README in each directory lists the setup steps and the least privilege the credential needs (a HEC token scoped to one index; an Entra application with only the Monitoring Metrics Publisher role on the one data collection rule). + +## Security and zero-knowledge + +Nothing here touches vault content. The payload stays the sanitized audit entry: identifiers plus whitelisted metadata, never a secret value, login, additional field, ciphertext or key (ADR-003). The formatters only reshape it. + +Stored encrypted (with Nextcloud `ICrypto`, the server's own key): the Splunk HEC token and the Sentinel client secret, in the new `credential_enc` column. These are integration credentials that a background job must use unattended, so the server necessarily holds them in a form it can decrypt, exactly like `hmac_secret_enc` today. They are not vault secrets, they are never returned by any API (the sink reports only `hasCredential`), and they are decrypted in memory for one request. + +Stored in plain text: the connector type, the format, the endpoint URL, and `connector_options` (tenant id, client id, data collection endpoint, rule id, stream name, index, sourcetype). None of these is a credential. + +The sink routes stay admin-only through the existing in-body `isAdmin()` gate. Sink lifecycle audit events add the connector type as an identifier and never the credential. + +## Risks / Trade-offs + +- **An administrator points a connector at an internal URL.** The endpoint is admin-configured, as for the webhook today; Keepiq requires `https://` for HEC, Sentinel and webhook endpoints and uses Nextcloud's `IClientService`, which applies Nextcloud's local-address protection. +- **Sentinel column drift.** If Keepiq adds a payload key later, the data collection rule drops it until the template is updated. The template and the formatter carry the same column list, and a test compares them. +- **CEF severity is a judgement.** The map is small and documented; an administrator who disagrees can re-map in the SIEM. +- **One token request per drain run** adds a round trip to Entra ID. Acceptable at the drain cadence. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests use a mocked `IClientService` and a local socket listener; no development fixture is needed. + +## Migration + +A new migration step adds three columns to `keepiq_siem_sinks`: `format` (`STRING(16)`, not null, default `json`), `credential_enc` (`TEXT`, nullable) and `connector_options` (`TEXT`, nullable, JSON). Existing sinks keep `type` `syslog` or `webhook` and get `format` `json`, so their behaviour does not change. The `` in `appinfo/info.xml` must bump so Nextcloud runs the step. diff --git a/openspec/changes/audit-siem-vendor-connectors/proposal.md b/openspec/changes/audit-siem-vendor-connectors/proposal.md new file mode 100644 index 000000000..8aeed07fa --- /dev/null +++ b/openspec/changes/audit-siem-vendor-connectors/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Ready-made Splunk, Microsoft Sentinel and CEF connectors for the SIEM export + +## Why + +Keepiq already streams its sanitized audit events to a SIEM, but only as a generic syslog line or a generic signed webhook. An administrator who runs Splunk or Microsoft Sentinel has to build the receiving side by hand: a collector, a parser and a table. The three competitors that rate yes ship named connectors instead. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| audit-14 | Use ready-made connectors for Splunk, Microsoft Sentinel or similar tools. | No named connectors or vendor formats; Splunk, Sentinel and similar tools can ingest the generic syslog or webhook stream, but the admin has to configure the receiving side. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +This row is partial. What is built: the generic RFC 5424 syslog transport (`lib/Service/SiemTransport.php:100`) and the generic HMAC-signed HTTPS webhook (`lib/Service/SiemTransport.php:152`), with queueing, retry, dead-lettering and test-fire. The missing half, from the decision: named Splunk, Microsoft Sentinel and CEF presets on the SIEM export. + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts:182 Microsoft Sentinel, :188 Rapid7, :195 Elastic, :201 Panther, :207 Sumo Logic, :228 Splunk (HEC, flag EventManagementForSplunk), :263 CrowdStrike and Datadog (flag); bitwarden/server@v2026.9.1 src/Core/Dirt/Enums/IntegrationType.cs:9 Hec, :10 Datadog ... Integrations page with SIEM connectors" +- 1Password: "https://support.1password.com/events-reporting/ : Splunk, Microsoft Sentinel, Datadog, Elastic, CrowdStrike and more (Business)" +- Keeper: "https://docs.keeper.io/enterprise-guide/event-reporting : built-in SIEM connectors for Splunk, Microsoft Sentinel, QRadar, Elastic, Datadog, Sumo Logic and more" + +## What Changes + +- A SIEM sink gets a connector choice. Next to the existing `syslog` and `webhook` transports, an administrator can pick `splunk_hec` (Splunk HTTP Event Collector) or `sentinel` (Microsoft Sentinel through the Azure Monitor Logs Ingestion API). +- A syslog sink gets a `format` choice: `json` (today's behaviour) or `cef` (ArcSight Common Event Format). CEF covers QRadar, ArcSight and Sentinel's own CEF connector through the Azure Monitor Agent. +- Each connector maps the same sanitized payload that `SiemService::buildPayload()` already builds. No connector adds a field that the audit whitelist does not carry. +- Connector credentials (the Splunk HEC token and the Sentinel client secret) are encrypted at rest with Nextcloud's `ICrypto` and are write-only, exactly like the webhook HMAC secret today. +- The admin SIEM section shows a connector picker with only the fields that connector needs. +- Keepiq ships receiving-side templates under `integrations/siem/`: an Azure Resource Manager template for the Sentinel data collection rule and custom table, and a Splunk `props.conf` for the `keepiq:audit` sourcetype. + +## Capabilities + +### New Capabilities + +- `siem-vendor-connectors`: named Splunk HEC, Microsoft Sentinel and CEF connectors on a SIEM sink, their credential handling, payload mapping and receiving-side templates. + +### Modified Capabilities + +None. The generic syslog and webhook behaviour of `siem-audit-export` stays as specified; this change adds requirements in its own capability. + +## Impact + +- **Backend**: `SiemSink` gains `format`, `credentialEnc` and `connectorOptions`; `SiemSinkService` validates each connector; `SiemTransport` gains a Splunk HEC and a Sentinel delivery path and a CEF formatter for syslog; new formatter classes under `lib/Service/Siem/`. +- **Frontend**: `src/components/settings/SiemSection.vue` gets a connector picker and per-connector fields. +- **Database**: three new nullable or defaulted columns on `keepiq_siem_sinks`; a new migration step and a `` bump. +- **Security**: no secret material enters any payload; the new credentials are server-held integration credentials, not vault secrets, and follow the HMAC secret's write-only rule. +- **Cross-app**: none. OpenConnector is not involved. diff --git a/openspec/changes/audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md b/openspec/changes/audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md new file mode 100644 index 000000000..6d37c9918 --- /dev/null +++ b/openspec/changes/audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md @@ -0,0 +1,116 @@ +## ADDED Requirements + +### Requirement: Named SIEM connectors on a sink + +The system MUST let an administrator create a SIEM sink with one of these connectors: `splunk_hec` (Splunk HTTP Event Collector), `sentinel` (Microsoft Sentinel through the Azure Monitor Logs Ingestion API), or a `syslog` sink with `format` `cef`. Existing `syslog` and `webhook` sinks with `format` `json` MUST behave exactly as before. A `format` of `cef` MUST be refused on any sink that is not `syslog`. + +#### Scenario: Administrator creates a Splunk connector + +- **GIVEN** an administrator on the SIEM section of the Nextcloud admin settings +- **WHEN** they call `POST /api/v1/siem/sinks` with `type` `splunk_hec`, an `https://` HEC endpoint and a HEC token +- **THEN** the sink MUST be stored as enabled and eligible for delivery +- **AND** the response MUST report `hasCredential` true and MUST NOT contain the token + +#### Scenario: CEF on a webhook is refused + +- **GIVEN** an administrator creating a sink +- **WHEN** they call `POST /api/v1/siem/sinks` with `type` `webhook` and `format` `cef` +- **THEN** the system MUST reject the request with a bad-request response +- **AND** no sink MUST be stored + +#### Scenario: An existing syslog sink is unchanged + +- **GIVEN** a `syslog` sink created before this change +- **WHEN** the migration runs and the next audit event is delivered +- **THEN** the sink MUST report `format` `json` +- **AND** the delivered message MUST be the same JSON payload as before the change + +### Requirement: Splunk HTTP Event Collector delivery + +The system MUST deliver to a `splunk_hec` sink by posting one event per request to the configured endpoint with the header `Authorization: Splunk ` and a body carrying `time`, `host`, `source` `keepiq`, `sourcetype` (default `keepiq:audit`), an optional `index`, and the sanitized payload as `event`. Delivery MUST count as successful only on HTTP 200 with a response `code` of 0; any other outcome MUST enter the existing retry and dead-letter path. + +#### Scenario: Accepted event + +- **GIVEN** an enabled `splunk_hec` sink and a queued audit event +- **WHEN** the delivery job posts the event and Splunk answers HTTP 200 with `code` 0 +- **THEN** the queue item MUST be marked delivered and the sink's last delivery status MUST be `ok` + +#### Scenario: Rejected token + +- **GIVEN** an enabled `splunk_hec` sink whose token Splunk rejects +- **WHEN** the delivery job posts a queued event and Splunk answers HTTP 403 +- **THEN** the item MUST be scheduled for retry with backoff +- **AND** after the retry ceiling it MUST be dead-lettered and an administrator notification MUST be raised + +### Requirement: Microsoft Sentinel delivery through the Logs Ingestion API + +The system MUST deliver to a `sentinel` sink by obtaining an Entra ID token with the client-credentials grant for scope `https://monitor.azure.com//.default` and posting the payload as a row with the columns `TimeGenerated`, `EventType`, `Category`, `ActorType`, `ActorId`, `ObjectType`, `ObjectId` and `Metadata` to `/dataCollectionRules//streams/?api-version=2023-01-01`. HTTP 204 MUST count as success. The token MUST be held in process memory for one drain run only and MUST NOT be written to any cache or table. A 401 MUST clear the token and retry once in the same run. + +#### Scenario: One token serves a drain run + +- **GIVEN** an enabled `sentinel` sink with two queued events +- **WHEN** the delivery job drains the queue +- **THEN** the system MUST request exactly one token +- **AND** it MUST post two rows, each accepted with HTTP 204 + +#### Scenario: Expired token is refreshed once + +- **GIVEN** a drain run whose cached token has expired at Entra ID +- **WHEN** the stream post answers HTTP 401 +- **THEN** the system MUST request a new token and retry the post once +- **AND** a second 401 MUST enter the normal retry path + +### Requirement: CEF formatting over syslog + +The system MUST send, for a `syslog` sink with `format` `cef`, a message body of the form `CEF:0|Conduction|Keepiq|||||` inside the existing RFC 5424 frame. Header fields MUST escape `\` and `|`. Extension values MUST escape `\`, `=` and line breaks. Severity MUST come from a fixed map keyed on the event category. + +#### Scenario: CEF line reaches QRadar + +- **GIVEN** a `syslog` sink with `format` `cef` pointing at a QRadar syslog listener +- **WHEN** an administrator force-revokes a suite and the `suite.revoked` event is delivered +- **THEN** the listener MUST receive one octet-framed RFC 5424 message whose MSG starts with `CEF:0|Conduction|Keepiq|` +- **AND** the severity field MUST be the value the map assigns to the `suite` category + +#### Scenario: A pipe in a value cannot break the header + +- **GIVEN** an audit event whose whitelisted metadata contains the characters `|` and `=` +- **WHEN** the event is formatted as CEF +- **THEN** every `|` in a header field MUST be escaped as `\|` +- **AND** every `=` in an extension value MUST be escaped as `\=` + +### Requirement: Connector credentials are write-only and encrypted at rest + +The system MUST store the Splunk HEC token and the Sentinel client secret encrypted with Nextcloud `ICrypto` in `credential_enc`, MUST never return either in any API response, audit entry, log line or payload, and MUST keep the stored value when an update supplies a blank credential. Non-credential connector settings (tenant id, client id, data collection endpoint, rule id, stream, index, sourcetype) MAY be stored and returned in plain text. + +#### Scenario: Reading a sink never reveals its credential + +- **GIVEN** a `sentinel` sink with a stored client secret +- **WHEN** an administrator calls `GET /api/v1/siem/sinks` +- **THEN** the sink entry MUST include `hasCredential` true and the tenant and client ids +- **AND** it MUST NOT include the client secret or its ciphertext + +#### Scenario: Blank credential on update keeps the stored one + +- **GIVEN** a `splunk_hec` sink with a stored token +- **WHEN** an administrator calls `PUT /api/v1/siem/sinks/{id}` with a new index and an empty credential +- **THEN** the index MUST change and the stored token MUST remain in effect + +### Requirement: Connector output carries no secret material + +The system MUST build every connector's output only from the sanitized payload that `SiemService::buildPayload()` returns plus fixed vendor constants. No connector MUST add a secret value, login, password, additional field, ciphertext or key material, and a forbidden metadata key MUST never reach any connector's output. + +#### Scenario: A planted forbidden key is dropped for every connector + +- **GIVEN** an audit event whose metadata contains a `value` key +- **WHEN** the event is formatted for JSON, CEF, Splunk HEC and Sentinel +- **THEN** none of the four outputs MUST contain the `value` key or its content + +### Requirement: Receiving-side templates ship with the app + +The system MUST ship an Azure Resource Manager template under `integrations/siem/sentinel/` that creates the `KeepiqAudit_CL` table and the data collection rule with stream `Custom-KeepiqAudit`, and a `props.conf` under `integrations/siem/splunk/` that defines the `keepiq:audit` sourcetype. The Sentinel template's column list MUST match the columns the Sentinel formatter sends. + +#### Scenario: Template and formatter agree + +- **GIVEN** the Sentinel template in `integrations/siem/sentinel/keepiq-dcr.json` +- **WHEN** the test suite compares its table columns with the Sentinel formatter's output keys +- **THEN** the two lists MUST be identical diff --git a/openspec/changes/audit-siem-vendor-connectors/tasks.md b/openspec/changes/audit-siem-vendor-connectors/tasks.md new file mode 100644 index 000000000..bf9de196c --- /dev/null +++ b/openspec/changes/audit-siem-vendor-connectors/tasks.md @@ -0,0 +1,43 @@ +# Tasks: SIEM vendor connectors + +## 1. Data model + +- [ ] 1.1 Add a migration step that adds `format` (default `json`), `credential_enc` and `connector_options` to `keepiq_siem_sinks`, and bump `` in `appinfo/info.xml`. Verify: a PHPUnit migration test asserts the three columns and that an existing sink reads back `format` `json`. +- [ ] 1.2 Extend `SiemSink` with the three fields; `jsonSerialize()` returns `format`, `connectorOptions` and `hasCredential` but never the credential. Verify: PHPUnit `SiemSinkTest` asserts no serialized key holds the credential value. +- [ ] 1.3 Extend `SiemSinkService` validation: accept `splunk_hec` and `sentinel` as `type`, require `https://` endpoints for them, require the Sentinel options, allow `format` `cef` only on `syslog`, and encrypt a supplied credential with `ICrypto` (blank keeps the stored one). Verify: PHPUnit `SiemSinkServiceTest` covers each accept and reject path. + +## 2. Formatters + +- [ ] 2.1 Add `lib/Service/Siem/JsonFormatter` (today's output, unchanged) and `CefFormatter` with header and extension escaping and the category severity map. Verify: PHPUnit covers escaping of `|`, `\`, `=` and line breaks, and one line per category. +- [ ] 2.2 Add `SplunkHecFormatter` (event envelope with time, host, source, sourcetype, optional index) and `SentinelRowFormatter` (the D3 columns). Verify: PHPUnit snapshots of both outputs for a fixed payload. +- [ ] 2.3 Add a guard test that feeds every formatter a payload holding a planted forbidden key and asserts the output carries only payload-derived values and fixed vendor constants. Verify: the PHPUnit test fails when a formatter reads outside the payload. + +## 3. Transports + +- [ ] 3.1 Route `syslog` sinks with `format` `cef` through `CefFormatter` in `SiemTransport::deliverSyslog()`. Verify: PHPUnit with a local TCP listener reads back an octet-framed RFC 5424 line whose MSG starts with `CEF:0|Conduction|Keepiq|`. +- [ ] 3.2 Add Splunk HEC delivery: `Authorization: Splunk `, success only on HTTP 200 with response `code` 0. Verify: PHPUnit with a mocked `IClientService` covers success, a non-2xx, and a 200 with a non-zero `code` entering the retry path. +- [ ] 3.3 Add Sentinel delivery: client-credentials token request, per-run token reuse, post to the stream URL, 204 as success, one retry after a 401. Verify: PHPUnit asserts one token request for two deliveries in a run, and the retry-once behaviour. +- [ ] 3.4 Make test-fire work for every connector through the existing `SiemService::testSink()`. Verify: PHPUnit asserts the outcome message per connector. + +## 4. Admin interface + +- [ ] 4.1 Add a connector picker to `src/components/settings/SiemSection.vue` (Splunk HTTP Event Collector, Microsoft Sentinel, CEF over syslog, syslog JSON, webhook JSON) with only the fields each connector needs and a write-only credential field. Verify: vitest asserts the field set per connector and that the credential field is never pre-filled. +- [ ] 4.2 Add a Playwright flow: an administrator creates a Splunk HEC sink on the SIEM section of Nextcloud admin settings, runs test-fire against an unreachable endpoint and sees the failure outcome. Verify: the Playwright spec passes in the E2E job. + +## 5. Receiving side + +- [ ] 5.1 Add `integrations/siem/sentinel/keepiq-dcr.json` (custom table `KeepiqAudit_CL` and the data collection rule) with a README. Verify: a PHPUnit test compares the template's column list with `SentinelRowFormatter`; manual check with `az deployment group what-if` against a test workspace. +- [ ] 5.2 Add `integrations/siem/splunk/props.conf` for the `keepiq:audit` sourcetype with a README. Verify: manual check in a Splunk development container that a test-fire event lands with parsed fields. +- [ ] 5.3 Document each connector's setup and least-privilege credential in `docs/`. Verify: manual review against the writing rules. + +## 6. Audit + +- [ ] 6.1 Add the connector type to the sink create and update audit metadata (identifiers only). Verify: PHPUnit asserts the audit metadata holds the type and never the credential. + +## Acceptance criteria + +- An administrator can create a Splunk HEC, Microsoft Sentinel or CEF syslog sink from the SIEM section, and existing syslog and webhook sinks keep working unchanged. +- Every connector sends only values derived from `SiemService::buildPayload()` plus fixed vendor constants. +- The HEC token and the Sentinel client secret are encrypted at rest, never returned by the API, and never written to a log or audit entry. +- A failed connector delivery enters the existing retry, dead-letter and notification path. +- The Sentinel template and the Sentinel formatter carry the same columns. diff --git a/openspec/changes/clients-extension-store-release/.openspec.yaml b/openspec/changes/clients-extension-store-release/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/clients-extension-store-release/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/clients-extension-store-release/design.md b/openspec/changes/clients-extension-store-release/design.md new file mode 100644 index 000000000..5f39ff9af --- /dev/null +++ b/openspec/changes/clients-extension-store-release/design.md @@ -0,0 +1,87 @@ +# Design: extension store release and one-time code on the next step + +## Context + +Code at development `4c214a9d`: + +- `browser-extension/build.mjs:1` bundles five entries with esbuild into `browser-extension/dist/` and copies one `manifest.json`. It has no target option, no packing and no signing. +- `browser-extension/manifest.json` is a single MV3 manifest: `background.service_worker`, permissions `storage`, `activeTab`, `tabs`, `scripting`, `clipboardWrite`, `idle`, `windows`, optional `webAuthenticationProxy`, and host permissions for every `http` and `https` page. It has no `browser_specific_settings`. `scripting` has no call site in `browser-extension/src/` (`chrome.windows` is used by `browser-extension/src/passkey/orchestrator.js:45`). Its `description` contains an em-dash, which the store listing copy must not carry. +- `.github/workflows/` has `cli-release.yml` (Go CLI, `cli-v*` tags) and `release.yml` (the Nextcloud app); nothing builds or ships the extension. Extension tests live in `tests/extension/` and run under the root vitest. +- `lib/Controller/ExtensionController.php:123` returns `capabilities` from `pair()`, but no version. +- Code fill: `browser-extension/src/background/service-worker.js:113` `doFill()` fills the login, then `:137` computes the code for the tab host and `:141` sends one `fill-otp` message. `browser-extension/src/content/content-script.js:115` `fillOtp()` looks for a visible field matching `OTP_SELECTORS` (`:30`) once. If the field arrives on the next page, nothing fills it. The existing spec already allows filling "on the current or post-submit page (re-detecting after in-origin navigation)" (`openspec/specs/extension-totp-autofill/spec.md`, requirement "Optional OTP-field fill with fallback"), but the code never re-detects. +- The vault key lives only in the worker's memory (`browser-extension/src/lib/vault.js:27`); termination of the worker locks the vault. + +## Goals / Non-Goals + +**Goals:** + +- A user installs Keepiq from the Chrome Web Store, Firefox Add-ons or Edge Add-ons. +- An administrator can force-install it for a whole organisation. +- Every published package is built by CI from a tagged commit, from source a reviewer can rebuild. +- The code fills itself on the second login step, on the same site, shortly after the password fill. + +**Non-Goals:** + +- Safari. It needs an Xcode wrapper app and Apple distribution; a later change. +- Self-hosted Chrome update manifests. Chrome only installs off-store extensions through enterprise policy anyway. +- Matching over shared and team-folder secrets: already returned by the match endpoint (see the proposal). +- Filling a code on a different site than the login (for example a separate identity provider domain). The intent is bound to the login's site on purpose. + +## Decisions + +### D1: One source tree, a manifest per browser + +`build.mjs --target chrome` keeps today's manifest. `--target firefox` writes `browser_specific_settings.gecko.id` (`keepiq@conduction.nl`) and a minimum Firefox version, declares the worker bundle under `background.scripts` because Firefox runs MV3 backgrounds as event pages, and drops the Chrome-only `webAuthenticationProxy`. Edge uses the Chrome package. The manifest `version` comes from the tag. + +Alternative considered: a cross-browser polyfill and one universal manifest. Rejected: Chrome rejects the Firefox keys and Firefox rejects `service_worker`, so a per-target manifest is simpler than a runtime shim. + +### D2: A release workflow modelled on `cli-release.yml` + +`.github/workflows/extension-release.yml` runs on pull requests and pushes touching `browser-extension/**`, `src/crypto/**` or `src/totp/**` (the extension bundles those web-app modules verbatim). It runs the `tests/extension` vitest suite, builds both targets twice and compares the hashes (a reproducibility check), runs `web-ext lint` on the Firefox build, and uploads both zips as artefacts. + +On a tag `extension-v` a second job, bound to a protected GitHub environment `extension-stores` that needs a maintainer's approval, uploads and publishes to the Chrome Web Store API, signs and submits a listed version with `web-ext sign` to AMO (with the source archive and build steps AMO asks for bundled code), submits to the Edge Add-ons API, signs an unlisted Firefox package for self-hosting, and attaches everything to a GitHub release. + +Alternative considered: publishing by hand from a maintainer's machine. Rejected: nobody could then show which commit a store package came from. + +### D3: Store credentials only in the protected environment + +The Chrome Web Store client id, client secret and refresh token, the AMO API issuer and secret, and the Edge client credentials are GitHub environment secrets of `extension-stores`. No pull-request workflow can read them. Placeholders in documentation look like `YOUR_AMO_JWT_ISSUER`. + +### D4: Listings and permissions pass review + +Each store listing carries a privacy policy page (in `docs/`) that states the zero-knowledge model: the extension sends the server only ciphertext and index fields, and stores only the pairing (server URL, user, app password) in extension storage. Each permission gets a one-line justification. `scripting` is removed because nothing calls it. The listing copy is written with the writing skill, so the manifest `description` loses its em-dash. + +### D5: Version handshake + +`pair()` adds `serverVersion` to its response. The extension carries a minimum server version constant and shows "Update Keepiq on your server to use this extension version" instead of failing on an unknown route. Store auto-updates can then move ahead of an organisation's server without silent breakage. + +### D6: A one-shot code intent, bound to tab, site and time + +After a successful login fill with a matched `totp` secret, the worker writes `{ tabId, site, totpSecretId, expiresAt }` to `chrome.storage.session`, where `site` is the registrable domain from `browser-extension/src/lib/match.js` and `expiresAt` is five minutes out. `storage.session` is held in memory by the browser and not written to disk. The intent holds no seed and no code. + +The content script watches for a visible field matching `OTP_SELECTORS` (on load and through a throttled `MutationObserver`). When one appears it sends `otp-field-detected` with its own hostname. The worker fills only if all of these hold: an intent exists for `sender.tab.id`, the sender frame's registrable domain equals the intent's `site`, the intent has not expired, and the vault is unlocked. It then decrypts the seed, computes the current code, sends `fill-otp` to that frame only, and deletes the intent. A second field on a later page gets nothing. + +Alternative considered: keeping the code itself in the intent. Rejected: a code is a credential for 30 seconds, and computing it fresh is cheap. Alternative considered: `chrome.webNavigation` to track the next page. Rejected: it needs a new permission, and an in-page step (a single-page app swapping the form) never navigates. + +## Security and zero-knowledge + +Nothing changes in what the server sees: ciphertext and the unencrypted `name` and `url` index fields, as ADR-003 and `browser-extension-autofill` require. The master password, the derived key and the vault `CryptoKey` stay in the worker's memory only. + +The code intent in `storage.session` holds a tab id, a registrable domain, a secret id and an expiry. None of these is secret material. The seed is decrypted transiently in the worker when the code is computed, exactly as today (`service-worker.js:163`). A page cannot trigger a code fill on another site, on another tab, after five minutes, or twice. + +Store credentials never reach the extension or the server. The signed packages contain only the bundled source that CI built from the tag. + +## Risks / Trade-offs + +- **The worker can be terminated between the two login steps.** Termination locks the vault, and a locked vault never fills. The user then unlocks and reads the code in the popup, as today. Keeping the worker alive longer would keep the key in memory longer, which this change does not do. +- **Store review can take days and can reject a release.** Releases are tagged separately from the app (`extension-v*`), so an app release never waits on a store. +- **Wide host permissions draw reviewer scrutiny.** They are needed to detect login fields on any site; the justification says so. +- **A site's code field may not match `OTP_SELECTORS`.** The clipboard copy stays as the fallback. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests use the existing `tests/extension` fixtures; a static test page with a two-step login form is added for the Playwright extension flow. + +## Migration + +None on the server: no table, no column, no `` bump. The extension's own manifest `version` moves with each `extension-v*` tag. diff --git a/openspec/changes/clients-extension-store-release/proposal.md b/openspec/changes/clients-extension-store-release/proposal.md new file mode 100644 index 000000000..01897ad9c --- /dev/null +++ b/openspec/changes/clients-extension-store-release/proposal.md @@ -0,0 +1,69 @@ +--- +kind: code +--- + +# Publish the browser extension in the stores and fill a one-time code on the next step + +## Why + +The Keepiq browser extension fills logins and one-time codes in code, but no user can install it without building it from source and loading it unpacked. There is no release or store pipeline: `.github/workflows/` holds `cli-release.yml` for the Go CLI and nothing for `browser-extension/`. And the one-time code is only filled when the code field is already on the page at fill time, while most sites ask for the code on the page after the password. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| clients-01 | Fill in logins on websites through a browser extension. | Autofill works in code: the popup lists owned secrets matching the site, decrypts in the worker and fills on click. The extension is not packaged or published, and matching only covers secrets the user owns, not shared or team-folder secrets. | +| clients-04 | Fill in the current one-time code together with the login. | After filling a login the worker looks for a totp-typed secret on the same host, fills a detected one-time-code field and copies the code with a 30 second clipboard clear. The OTP field has to be on the page at fill time, and the extension is not distributed. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Both rows are partial. + +- clients-01. Built: URL matching (`browser-extension/src/popup/popup.js:45` to `lib/Controller/ExtensionController.php:180`), decrypt in the worker (`browser-extension/src/background/service-worker.js:113`) and fill (`browser-extension/src/content/content-script.js:95`). Missing, from the decision: a packaged, signed extension in the browser stores, and matching over shared and team-folder secrets. The second half does not hold up against the code and is not specified here: a shared or team-folder copy is a `Secret` row owned by the recipient (`lib/Service/RecipientSecretCopyService.php:112` sets `ownerType` `user` and `:113` sets `ownerId` to the recipient; team folders create their copies through the same service at `lib/Service/TeamFolderShareService.php:295`), so the owner-scoped match at `lib/Controller/ExtensionController.php:195` already returns them. +- clients-04. Built: filling a code field present at fill time (`browser-extension/src/background/service-worker.js:137` to `:141`, `browser-extension/src/content/content-script.js:115`) and copying the code. Missing, from the decision: filling a one-time code field that appears after the login step. + +### Demand + +No demand row. + +### Competitors rated yes + +clients-01: + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/autofill/services/autofill.service.ts:481 doAutoFill, :712 doAutoFillActiveTab; apps/browser/src/manifest.v3.json:137 autofill_login shortcut ..." +- 1Password: "https://support.1password.com/save-fill-passwords/ : 'After you've saved your username and password for a website, 1Password can fill them'" +- Passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/all/background_page/pagemod/webIntegrationPagemod.js injects the web integration; src/all/background_page/controller/autofill/AutofillController.js:149 fillCredentials ..." +- Keeper: "https://docs.keeper.io/user-guides/browser-extensions : KeeperFill 'Autofill Your Passwords' on websites" +- Nextcloud Passwords: "not in the cloned repos, docs rating kept: marius-wieschollek/passwords@2026.9.0 src/js/Services/AppStoreService.js:19 ... https://git.mdns.eu/nextcloud/passwords/-/wikis/Administrators/Feature-Comparison ..." + +clients-04: + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/autofill/services/autofill.service.ts:104 TotpService injected, :427 autoCopyTotp$ copies the current code to the clipboard after filling ..." +- 1Password: "https://support.1password.com/one-time-passwords/ : '1Password automatically fills your one-time password'" +- Passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/all/background_page/controller/autofill/AutofillController.js:99 reads totp from the decrypted secret, :101 fillCredentials with username, password and totp ..." +- Keeper: "https://docs.keeper.io/user-guides/browser-extensions#autofilling-2fa-codes : 'Upon autofilling your username and password via KeeperFill, when prompted, a stored two-factor code will also be autofilled'" + +## What Changes + +- A release pipeline for `browser-extension/`: tested, built per browser, packed, and on an `extension-v*` tag submitted to the Chrome Web Store, Firefox Add-ons (AMO) and Microsoft Edge Add-ons, with the packages attached to a GitHub release. +- `browser-extension/build.mjs` gains a `--target chrome|firefox` option that writes the right manifest for each browser. +- Store listings with a privacy policy, a justification per permission, and user-facing copy that follows the writing rules. The unused `scripting` permission is removed. +- A version handshake: the pair response carries the Keepiq server version, and the extension tells the user when the server is too old for it. +- Enterprise rollout documentation: force-install by store id through Chrome and Edge policy, and a signed Firefox package on the GitHub release. +- After a login fill, the extension remembers a short-lived, one-shot intent to fill the one-time code on that tab. When a code field appears on the same site within five minutes, the extension computes the current code and fills it. + +## Capabilities + +### New Capabilities + +- `extension-store-release`: packaging, signing, publishing and versioning of the browser extension. + +### Modified Capabilities + +- `extension-totp-autofill`: adds a requirement for filling the one-time code on the step after the login. + +## Impact + +- **Backend**: `ExtensionController::pair()` adds the server version to its response (`lib/Controller/ExtensionController.php:123` already returns capabilities). No new route. +- **Frontend**: extension only: `build.mjs`, `manifest.json`, `service-worker.js`, `content-script.js`, the popup. The web app is untouched. +- **Database**: none. No migration and no `` bump for the server. +- **Security**: store credentials live in a protected GitHub environment; the pending code intent holds no seed and no code; the zero-knowledge model of `browser-extension-autofill` is unchanged. +- **Cross-app**: none. diff --git a/openspec/changes/clients-extension-store-release/specs/extension-store-release/spec.md b/openspec/changes/clients-extension-store-release/specs/extension-store-release/spec.md new file mode 100644 index 000000000..ca74cb472 --- /dev/null +++ b/openspec/changes/clients-extension-store-release/specs/extension-store-release/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: The extension is published in the browser stores + +The system MUST publish the browser extension to the Chrome Web Store, Firefox Add-ons and Microsoft Edge Add-ons from a CI job that runs on an `extension-v` tag, and MUST attach the built packages, including a signed Firefox package for self-hosting, to a GitHub release for that tag. The publishing job MUST run in a protected GitHub environment that a maintainer approves, and store credentials MUST NOT be readable by any pull-request workflow. + +#### Scenario: A tag ships to the stores + +- **GIVEN** a maintainer pushes the tag `extension-v1.2.0` +- **WHEN** a maintainer approves the `extension-stores` environment for the release job +- **THEN** the job MUST submit the Chrome package to the Chrome Web Store, the Firefox package to Firefox Add-ons and the Chrome package to Edge Add-ons +- **AND** the GitHub release `extension-v1.2.0` MUST hold the Chrome zip, the Firefox package and the signed unlisted Firefox package + +#### Scenario: A pull request cannot reach store credentials + +- **GIVEN** a pull request that changes `browser-extension/manifest.json` +- **WHEN** the extension workflow runs for that pull request +- **THEN** it MUST build, lint and test both targets +- **AND** it MUST NOT have access to any `extension-stores` secret + +### Requirement: Packages are built from source per browser and reproducibly + +The system MUST build a Chrome manifest with `background.service_worker` and a Firefox manifest with `browser_specific_settings.gecko.id` and `background.scripts` from one source tree, MUST take the manifest `version` from the release tag, and MUST fail the workflow when two builds of the same commit produce different packages. + +#### Scenario: Two builds match + +- **GIVEN** the extension workflow on any commit +- **WHEN** it builds the Firefox target twice +- **THEN** the two package hashes MUST be equal, or the workflow MUST fail + +### Requirement: Listings carry a privacy policy and least permissions + +The system MUST ship each store listing with a privacy policy that states the extension sends the server only ciphertext and the unencrypted `name` and `url` index fields, and stores only the pairing in extension storage. The manifest MUST NOT request a permission that no extension code uses. + +#### Scenario: An unused permission fails the build + +- **GIVEN** a manifest that lists `scripting` +- **WHEN** the extension test suite checks every requested permission against its call sites in `browser-extension/src/` +- **THEN** the test MUST fail naming `scripting` + +### Requirement: The extension checks the server version on pairing + +The system MUST return the Keepiq server version from `POST /api/v1/extension/pair`, and the extension MUST show an update message instead of the vault view when the server version is below the extension's minimum. + +#### Scenario: Old server, new extension + +- **GIVEN** a store-updated extension whose minimum server version is newer than the paired Keepiq server +- **WHEN** a vault owner opens the popup +- **THEN** the popup MUST say the Keepiq server needs an update +- **AND** the extension MUST NOT call `GET /api/v1/extension/match` + +### Requirement: Organisations can force-install the extension + +The system MUST document how an administrator force-installs the extension by store id through Chrome and Edge enterprise policy, and how to deploy the signed Firefox package through Firefox enterprise policy. + +#### Scenario: Chrome policy install + +- **GIVEN** an administrator who adds the Keepiq store id to `ExtensionInstallForcelist` +- **WHEN** a managed Chrome profile starts +- **THEN** the Keepiq extension MUST be installed and shown in the toolbar without user action diff --git a/openspec/changes/clients-extension-store-release/specs/extension-totp-autofill/spec.md b/openspec/changes/clients-extension-store-release/specs/extension-totp-autofill/spec.md new file mode 100644 index 000000000..1a5a804aa --- /dev/null +++ b/openspec/changes/clients-extension-store-release/specs/extension-totp-autofill/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: One-time code fill on the step after the login + +After filling a login that has a matched `totp` secret, the extension MUST keep a one-shot fill intent holding only the tab id, the login's registrable domain, the `totp` secret id and an expiry five minutes out, in `chrome.storage.session` and never in `storage.local` or `storage.sync`. When a visible one-time-code field appears later in that tab, the extension MUST compute the current code and fill it only if the field's frame has the same registrable domain, the intent has not expired, and the vault is unlocked. It MUST then delete the intent. Lock and unpair MUST delete every intent. + +#### Scenario: The code page follows the password page + +- **GIVEN** an unlocked extension and a vault owner who fills a login on `example.com` whose `totp` secret matches `example.com` +- **WHEN** the site shows a one-time-code field on the next page within five minutes +- **THEN** the extension MUST fill that field with the current code +- **AND** a code field on any later page MUST NOT be filled + +#### Scenario: Another site cannot collect the code + +- **GIVEN** a pending intent for `example.com` in a tab +- **WHEN** that tab navigates to `attacker.example.net` and the page shows a one-time-code field +- **THEN** the extension MUST NOT compute or fill a code + +#### Scenario: A locked vault fills nothing + +- **GIVEN** a pending intent for `example.com` +- **WHEN** the vault locks before the code field appears +- **THEN** the intent MUST be deleted and no code MUST be filled +- **AND** the popup MUST still offer the code once the vault owner unlocks + +#### Scenario: The intent never holds secret material + +- **GIVEN** a pending intent after a login fill +- **WHEN** the test suite reads `chrome.storage.session` +- **THEN** the stored intent MUST contain no seed and no code diff --git a/openspec/changes/clients-extension-store-release/tasks.md b/openspec/changes/clients-extension-store-release/tasks.md new file mode 100644 index 000000000..c895d39fa --- /dev/null +++ b/openspec/changes/clients-extension-store-release/tasks.md @@ -0,0 +1,38 @@ +# Tasks: extension store release and one-time code on the next step + +## 1. Build per browser + +- [ ] 1.1 Add `--target chrome|firefox` to `browser-extension/build.mjs`, writing the Firefox manifest keys from D1 and a version taken from an `EXTENSION_VERSION` variable. Verify: a vitest in `tests/extension/` builds both targets into a temp dir and asserts the Firefox manifest has `browser_specific_settings.gecko.id` and `background.scripts`, and the Chrome manifest has `background.service_worker`. +- [ ] 1.2 Remove the unused `scripting` permission and replace the manifest `description` with store copy written with the writing skill. Verify: `grep -rn "chrome.scripting" browser-extension/src` returns nothing, and the dash grep on `manifest.json` is clean. + +## 2. Release pipeline + +- [ ] 2.1 Add `.github/workflows/extension-release.yml` for pull requests and pushes touching `browser-extension/**`, `src/crypto/**` or `src/totp/**`: vitest `tests/extension`, both builds, a second build with a hash comparison, `web-ext lint`, and zip artefacts. Verify: the workflow runs green on the pull request that adds it. +- [ ] 2.2 Add the tag job for `extension-v*` bound to the protected `extension-stores` environment: Chrome Web Store upload and publish, AMO listed signing with the source archive, Edge Add-ons submission, unlisted Firefox signing, and a GitHub release with all packages. Verify: a dry run on a pre-release tag against the stores' test or unlisted channels, checked by hand. +- [ ] 2.3 Document the store credentials each secret holds, with placeholder values only, and who approves the environment. Verify: manual review; gitleaks passes. + +## 3. Store listings and rollout + +- [ ] 3.1 Write the privacy policy page and one justification per permission in `docs/`, with the writing skill. Verify: manual review against the writing rules and the stores' listing checklists. +- [ ] 3.2 Document enterprise rollout: Chrome and Edge `ExtensionInstallForcelist` by store id, and the signed Firefox package with Firefox enterprise policy. Verify: manual install through policy on one Chrome and one Firefox profile. + +## 4. Version handshake + +- [ ] 4.1 Add `serverVersion` to the `pair()` response in `lib/Controller/ExtensionController.php`. Verify: PHPUnit `ExtensionControllerTest` asserts the field equals the installed app version. +- [ ] 4.2 Add a minimum server version constant to the extension and an "update your server" state in the popup. Verify: vitest with a mocked pair response below the minimum asserts the popup shows the update state and sends no match request. + +## 5. One-time code on the next step + +- [ ] 5.1 After a login fill with a matched `totp` secret, write the one-shot intent to `chrome.storage.session` in `service-worker.js`, and clear all intents on lock and unpair. Verify: vitest with a mocked `chrome.storage.session` asserts the stored intent holds no seed and no code, and that lock clears it. +- [ ] 5.2 In `content-script.js`, watch for a visible code field on load and through a throttled `MutationObserver`, and send `otp-field-detected` once per field. Verify: vitest with a jsdom page that inserts the field after a delay asserts exactly one message. +- [ ] 5.3 In the worker, fill only when the tab, the registrable domain, the expiry and the unlocked state all match, then delete the intent. Verify: vitest covers a fill on the same site, and refusals for another tab, another site, an expired intent, a locked vault and a second field. +- [ ] 5.4 Add a Playwright flow with the unpacked Chrome build: a vault owner fills a login on a two-step test page and the code field on step two is filled. Verify: the Playwright spec passes locally and in the E2E job. + +## Acceptance criteria + +- A tagged `extension-v*` release produces signed packages for Chrome, Firefox and Edge from CI, attached to a GitHub release, after a maintainer approves the store job. +- Two builds of the same commit produce identical packages. +- No store credential is readable from a pull-request workflow. +- The extension tells the user when the paired server is older than it supports. +- The one-time code fills on the next login step on the same site within five minutes, at most once, and only while the vault is unlocked. +- The pending code intent never holds a seed or a code. diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/.openspec.yaml b/openspec/changes/clients-extension-unlock-lock-and-accounts/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/design.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/design.md new file mode 100644 index 000000000..81e791667 --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/design.md @@ -0,0 +1,90 @@ +# Design: extension biometric unlock, chosen lock delay, and several accounts + +## Context + +Code at development `4c214a9d`: + +- `browser-extension/src/lib/api.js:13` stores one pairing (`{ url, user, appPassword }`) under the key `keepiq.config`; `loadConfig()` (`:16`) returns it or null. Every API call takes that one config. +- `browser-extension/src/lib/vault.js:27` to `:30` keeps one `cryptoKey`, `publicKey`, `suiteId` and `idleTimer` in module scope. `unlock()` (`:49`) fetches the active suite and calls `decryptPrivateKey(suite.privateKey, masterPassword)`; `armIdleLock()` (`:114`) sets the timer. +- `browser-extension/src/background/service-worker.js:26` fixes `DEFAULT_IDLE_MINUTES = 15`; `idleMs()` (`:31`) reads `config.idleMinutes`, which nothing writes. `:254` locks on the OS `locked` idle state. `getState()` (`:42`) reports one `user` and one `url`. +- `browser-extension/src/popup/popup.js:17` switches three views (pair, locked, unlocked); `:182` sends the master password to the worker with `send('unlock', { masterPassword })`. +- `browser-extension/src/crypto/index.js` re-exports `decryptPrivateKey` but not `decryptPrivateKeyWithRawKey` or `deriveUnlockKeyRaw` (both in `src/crypto/aes.js:104` and `:132`), nor the PRF helpers in `src/crypto/passkey.js` (`deriveKekFromPrf` `:72`, `wrapUnlockKey` `:100`, `unwrapUnlockKey` `:119`). +- The web app's passkey unlock: `src/store/modules/passkey.js:94` `enroll(masterPassword, label)` and `:193` `unlockWithPasskey()`, with `RP_ID = window.location.hostname` (`:26`) and `userVerification: 'preferred'`. The server side is `lib/Controller/PasskeyController.php` (`create` `:137`, `loginOptions` `:180`) and `lib/Service/PasskeyService.php` (`enroll` `:95`, `loginOptions` `:134` filtering on `unlock_key_epoch`, `markStaleOnPasswordChange` `:205`, `deleteAllOnRotation` `:217`). The table is `passkey_credentials` in `lib/Migration/Version001000Date20260908000000.php:487`. Routes are `appinfo/routes.php:241` to `:246`. +- The passkey provider already opens a small extension window for a consent step with `chrome.windows.create` (`browser-extension/src/passkey/orchestrator.js:45`). +- Admin session settings live in `src/components/settings/SessionTimeoutSection.vue`; `lib/Service/AdminSettingsService.php:55` allows web session timeouts `session`, `10min`, `30min`. + +## Goals / Non-Goals + +**Goals:** + +- A user chooses the extension's idle lock delay, within an administrator's maximum. +- A user pairs up to five accounts on one or more servers and switches between them in one click. +- A user unlocks the extension with a fingerprint or face through a platform passkey with PRF, where the browser allows it. + +**Non-Goals:** + +- Biometric unlock in the Go CLI. The row names the extension; the CLI stays on the master password. +- Showing candidates from several accounts at once. Matching and filling use the active account; a merged view is a later change. +- Biometric unlock through a native helper app (the Bitwarden desktop route). Keepiq has no native app (`openspec/specs/mobile-pwa/spec.md:11`). +- Remembering an unlocked state across a browser restart. + +## Decisions + +### D1: The idle delay is a per-account setting with an administrator maximum + +The popup's new settings view offers 1, 5, 15, 30, 60 and 240 minutes and stores the choice as `idleMinutes` on the account in `storage.local` (not sensitive). On every unlock the worker calls a new `GET /api/v1/extension/policy`, which returns `maxIdleMinutes` from the app config key `extension_max_idle_minutes` (default 240, set in `SessionTimeoutSection.vue`), and clamps the choice to it. The lock on OS lock, worker termination and manual lock stays unconditional. + +Alternative considered: reusing the web app's `session_timeout` preference. Rejected: its values (`session`, `10min`, `30min`) describe a browser tab, and a user may want a shorter delay in the extension, which fills forms on any site. + +### D2: An account list replaces the single pairing + +Storage moves to `keepiq.accounts` (a list of `{ id, url, user, appPassword, label, idleMinutes }`) and `keepiq.activeAccountId`. On worker start, an existing `keepiq.config` becomes the first account and the old key is removed. The limit is five accounts. + +`vault.js` keeps a map from account id to `{ cryptoKey, publicKey, suiteId, idleTimer }`. Each account locks on its own timer; OS lock and worker termination lock them all. The blob cache from `doMatch()` is keyed by account. Every worker message that reads or fills carries the account id, and the worker refuses a fill for a secret id that came from another account's match. A captured login is saved to the active account, and the save prompt names that account. + +The popup header shows the active account (`user@host`) with a menu listing the others, their lock state, and "Add account". Unpair removes one account. + +Alternative considered: one account unlocked at a time, switching locks the previous one. Rejected: a user switching back and forth would re-enter a password every time, which invites weak master passwords. + +### D3: Biometric unlock is a PRF passkey owned by the extension + +The extension enrols its own platform credential; it cannot use the web app's, because that one is bound to the Nextcloud host as relying party. The ceremony runs in a small extension window opened with `chrome.windows.create`, like the passkey consent window, because the OS prompt takes focus and would close the popup. The relying party is the extension's own origin. The request asks for `authenticatorAttachment: 'platform'`, `userVerification: 'required'` and the `prf` extension. + +Enrolment: the user enters the master password once in that window. The window derives the raw unlock key with `deriveUnlockKeyRaw` from the suite envelope's salt, checks it against the envelope, runs `create()` and a `get()` with a fresh PRF salt, derives the key-encryption key with `deriveKekFromPrf`, wraps the raw unlock key with `wrapUnlockKey`, and asks the worker to post the credential. This is the web app's recipe (`src/store/modules/passkey.js:94`), reused, not re-implemented. + +Unlock: the window fetches the login options through the worker, runs `get()` with the stored PRF salt, derives the key-encryption key, unwraps the raw unlock key, and sends it to the worker over the extension's internal messaging, the same channel the popup already uses for the master password. The worker calls `decryptPrivateKeyWithRawKey`, imports the non-extractable key, and the window closes. + +Alternative considered: keeping the wrapped key only in extension storage. Rejected: the user could not see or revoke it from the web app, and it would escape the server's `unlock_key_epoch` invalidation on a password change or rotation. + +### D4: Server-side, extension credentials sit next to web credentials + +`passkey_credentials` gains `client_kind` (`web` default, or `extension`) and `rp_id`. `POST /api/v1/passkeys` accepts both; `GET /api/v1/passkeys/login-options` takes `client` and `rpId` query parameters and returns only matching credentials, so the web app never offers an extension credential and the reverse. `PasskeyManager.vue` labels extension credentials "Browser extension" and revokes them the same way. The existing epoch check and `deleteAllOnRotation()` apply unchanged. + +### D5: Feature detection, not browser lists + +The unlock option appears only when the extension page exposes `PublicKeyCredential`, `isUserVerifyingPlatformAuthenticatorAvailable()` returns true, and enrolment reports `prf.enabled`. A browser that refuses WebAuthn from an extension origin, or an authenticator without PRF, shows the master password form only. Which browsers pass is recorded in the extension README after the implementation checks them. + +## Security and zero-knowledge + +The server stores, per extension credential: the credential id, the PRF salt, the AES-256-GCM wrapped unlock key, the epoch, the label, `client_kind` and `rp_id`. It never receives the master password, the raw unlock key, the PRF output or the key-encryption key (ADR-003, and the `passkey-vault-login` rules). A stolen database row is useless without the user's authenticator and a successful user verification. + +The raw unlock key exists briefly in the unlock window and in the worker, never in `storage.*`. The window closes after the handoff. The worker keeps only the non-extractable `CryptoKey` it already keeps today. + +Extension storage holds, per account: the server URL, the user, the Nextcloud app password, a label and the idle delay. None of it is key material; revoking the app password in Nextcloud cuts the account off, as today. + +Accounts cannot read each other's data: key material, blob caches and timers are keyed by account, and a fill request is checked against the account that produced the match. + +## Risks / Trade-offs + +- **Browser support for WebAuthn in extension pages varies.** D5 hides the option where it fails; the master password always works. +- **A shorter maximum set later by an administrator** takes effect at the next unlock, not immediately. Acceptable: the lock on OS lock is unconditional. +- **Five unlocked accounts hold five keys in memory.** Each has its own timer, and one OS lock clears them all. +- **Storage migration from `keepiq.config`** runs once; a test covers an upgrade from the old shape. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests use a virtual WebAuthn authenticator (Chrome DevTools protocol in Playwright) and the existing `tests/extension` fixtures. + +## Migration + +A migration step adds `client_kind` (`STRING(16)`, not null, default `web`) and `rp_id` (`STRING(255)`, nullable) to `keepiq_passkey_credentials`. Existing rows become `web`. The `` in `appinfo/info.xml` must bump. The extension migrates its own `keepiq.config` to `keepiq.accounts` on first start after the update. diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/proposal.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/proposal.md new file mode 100644 index 000000000..78805de5a --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/proposal.md @@ -0,0 +1,73 @@ +--- +kind: code +--- + +# Extension unlock with a fingerprint or face, a chosen lock delay, and several accounts + +## Why + +The browser extension unlocks only with the master password, locks after a fixed 15 minutes, and knows one Nextcloud account at a time. The web app already unlocks with a platform passkey, users ask to pick their own lock delay, and people with a work and a personal server have to unpair to switch. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| clients-06 | The extension locks itself automatically. | The extension locks after 15 idle minutes, on OS or browser lock, on worker termination and on demand. The idle period cannot be changed because nothing ever writes config.idleMinutes, and the extension is not distributed. | +| clients-21 | Switch between several accounts or servers in the same app or extension. | The extension pairs with one Nextcloud account at a time; switching means unpairing. | +| crypto-09 | Unlock with a fingerprint or face scan on your device. | Fingerprint or face unlock works in the web app only by enrolling a platform passkey (Touch ID, Windows Hello) whose browser supports PRF. The browser extension and CLI have no biometric unlock. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +- clients-06 is partial. Built: the fixed 15 minute idle lock (`browser-extension/src/background/service-worker.js:26` and `:37`, `browser-extension/src/lib/vault.js:114`), the lock on OS lock (`service-worker.js:254`) and the Lock button (`browser-extension/src/popup/popup.js:197`). Missing, from the decision: a user-chosen idle period. The worker reads `config.idleMinutes` (`service-worker.js:33`) but nothing writes it. Distribution is covered by the change `clients-extension-store-release`. +- clients-21 is not built: `browser-extension/src/lib/api.js:13` to `:31` stores one config under one key. +- crypto-09 is partial. Built: the web app's PRF passkey unlock (`src/components/PasskeyManager.vue:25`, `src/store/modules/passkey.js:193`). Missing, from the decision: fingerprint or face unlock in the browser extension. The popup unlocks with the master password only (`popup.js:182`). + +### Demand + +- clients-21, featureRequest: https://community.bitwarden.com/t/account-switching/716 + +No demand row for clients-06 or crypto-09. + +### Competitors rated yes + +clients-06: + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/key-management/vault-timeout/vault-timeout.service.ts; libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts:59 checkVaultTimeout ..." +- 1Password: "https://support.1password.com/unlock-auto-lock/ : idle auto-lock, and 'when you quit your browser, 1Password will always lock'" +- Passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/all/background_page/service/auth/startLoopAuthSessionCheckService.js:19 checks the server session every 60 s and logs the extension out when it expired ..." +- Keeper: "https://docs.keeper.io/user-guides/browser-extensions#stay-logged-in : 'Inactivity Logout Timer which automatically logs you out of Keeper after a period of inactivity'; admin Logout Timer policy" + +clients-21: + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/auth/popup/account-switching/account-switcher.component.ts:40 switcher page; apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts:72 ACCOUNT_LIMIT, :93 add account entry ..." +- 1Password: "https://support.1password.com/multiple-accounts/ : add multiple accounts to the apps and browser extension, see all items at once or 'Switch to a specific account'." +- Keeper: "https://docs.keeper.io/user-guides/tips-and-tricks/personal-and-business-vaults : switch between business and personal accounts in the web vault, browser extension and mobile apps ('Switch Account', 'Add Account')." + +crypto-09: + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/desktop/src/key-management/biometrics/main-biometrics.service.ts, native-v2/os-biometrics-linux.service.ts; apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts (extension unlock via desktop) ..." +- 1Password: "https://support.1password.com/windows-hello/ and https://support.1password.com/face-id/ : unlock with face, fingerprint, Touch ID" +- Keeper: "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#device-biometrics : 'Keeper natively supports Windows Hello, Touch ID, Face ID and Android biometrics'" + +## What Changes + +- The popup gets a settings view where the user picks the idle lock delay per account: 1, 5, 15 (default), 30, 60 or 240 minutes. An administrator can set a maximum, which the extension enforces. +- The extension holds up to five paired accounts, each with its own server, lock state, idle timer and settings. The popup header switches between them; matching and filling use the active account only. +- The extension can enrol a platform passkey with the WebAuthn PRF extension (Touch ID, Windows Hello, a phone or laptop fingerprint or face sensor) and then unlock with it instead of the master password. The wrapped unlock key is stored server-side next to the web app's passkey envelopes, so the web app lists and revokes it. + +## Capabilities + +### New Capabilities + +- `extension-biometric-unlock`: PRF passkey enrolment and unlock inside the browser extension. +- `extension-account-switching`: several paired accounts in one extension. + +### Modified Capabilities + +- `browser-extension-autofill`: adds a requirement for the user-chosen idle lock period with an administrator maximum. + +## Impact + +- **Backend**: `passkey_credentials` gains `client_kind` and `rp_id`; `PasskeyService::enroll()` and `loginOptions()` filter by them; the org policy response carries `extensionMaxIdleMinutes`. +- **Frontend**: extension popup (settings view, account switcher, biometric unlock button), a new extension window for the WebAuthn ceremony, `vault.js` and `api.js` refactored to per-account state. In the web app, `PasskeyManager.vue` labels extension credentials. +- **Database**: two new columns on `keepiq_passkey_credentials`; a migration and a `` bump. +- **Security**: the server stores only a PRF-wrapped unlock key, as for the web app; the master password, the raw unlock key and the PRF output never reach it. Accounts are isolated from each other in the worker. +- **Cross-app**: none. diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md new file mode 100644 index 000000000..6f668fb25 --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md @@ -0,0 +1,18 @@ +## ADDED Requirements + +### Requirement: User-chosen idle lock period with an administrator maximum + +The extension MUST let the user choose the idle lock period per paired account from 1, 5, 15, 30, 60 and 240 minutes, with 15 as the default, and MUST store the choice in extension storage. On every unlock the extension MUST read `maxIdleMinutes` from `GET /api/v1/extension/policy` and MUST use the lower of the user's choice and that maximum. The lock on OS or browser lock, on worker termination and on manual lock MUST stay in force whatever the period. + +#### Scenario: A user picks five minutes + +- **GIVEN** a vault owner with an unlocked extension and an administrator maximum of 240 minutes +- **WHEN** they choose 5 minutes in the popup settings view and leave the browser idle for 5 minutes +- **THEN** the extension MUST lock and the next fill MUST ask for an unlock + +#### Scenario: The administrator maximum wins + +- **GIVEN** a vault owner who chose 240 minutes +- **WHEN** an administrator sets the extension maximum to 30 minutes in the Keepiq admin settings and the owner next unlocks the extension +- **THEN** the extension MUST lock after 30 idle minutes +- **AND** the popup settings view MUST show that 60 and 240 minutes exceed the organisation's maximum diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md new file mode 100644 index 000000000..f10413fda --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Up to five paired accounts in one extension + +The extension MUST let a user pair up to five Nextcloud accounts, on the same or different servers, each with its own app password, lock state, idle timer and settings, and MUST refuse a sixth pairing with a clear message. An extension paired before this change MUST keep its pairing as the first account without asking the user to pair again. + +#### Scenario: Work and personal servers side by side + +- **GIVEN** a user who has paired `alice@cloud.work.example` in the extension +- **WHEN** they choose "Add account" in the popup and pair `alice@cloud.home.example` +- **THEN** the popup header MUST list both accounts +- **AND** the first account MUST keep its lock state + +#### Scenario: An existing pairing survives the update + +- **GIVEN** an extension paired under the old single-pairing storage +- **WHEN** the updated extension starts +- **THEN** the pairing MUST appear as the first account and the old storage key MUST be removed + +### Requirement: Switching and isolation between accounts + +The extension MUST show the active account in the popup header and switch to another account in one click. Matching, filling, the one-time code and save prompts MUST use the active account only. Key material, cached blobs and idle timers MUST be kept per account, and the worker MUST refuse to fill a secret id that did not come from the active account's own match. + +#### Scenario: Switching changes the candidates + +- **GIVEN** two unlocked accounts with different logins for `example.com` +- **WHEN** the user switches the active account in the popup header while on `example.com` +- **THEN** the candidate list MUST show only the newly active account's logins + +#### Scenario: A cross-account fill is refused + +- **GIVEN** a match result from account A +- **WHEN** a fill message arrives for one of those secret ids while account B is active +- **THEN** the worker MUST refuse the fill and decrypt nothing + +#### Scenario: Each account locks on its own timer + +- **GIVEN** account A with a 5 minute idle period and account B with a 60 minute idle period, both unlocked +- **WHEN** 5 idle minutes pass +- **THEN** account A MUST be locked and account B MUST stay unlocked +- **AND** an OS lock MUST lock both diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md new file mode 100644 index 000000000..4fae2f735 --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Enrol a platform passkey for extension unlock + +The extension MUST let a user with a known master password enrol a platform authenticator (fingerprint or face) that supports the WebAuthn `prf` extension, with the extension's own origin as relying party and `userVerification` `required`. The extension MUST wrap the raw vault unlock key with an AES-256-GCM key derived from the PRF output and MUST send the server only the credential metadata, the PRF salt and the wrapped key, stored with `client_kind` `extension`. The master password, the raw unlock key and the PRF output MUST NOT reach the server. + +#### Scenario: Enrolment stores only a wrapped key + +- **GIVEN** a vault owner with a paired extension on a laptop with Windows Hello +- **WHEN** they choose "Unlock with fingerprint or face" in the popup, confirm their master password and pass the Windows Hello prompt +- **THEN** `POST /api/v1/passkeys` MUST receive a credential with `client_kind` `extension`, a PRF salt and a wrapped unlock key +- **AND** the request MUST NOT contain the master password, the raw unlock key or the PRF output + +### Requirement: Unlock the extension with the enrolled passkey + +The extension MUST let the user unlock with the enrolled credential: it MUST request the login options for `client` `extension` and its own relying party, run the WebAuthn ceremony with the stored PRF salt, unwrap the raw unlock key in an extension page, hand it to the worker over internal extension messaging, and import the private key as a non-extractable `CryptoKey`. The raw unlock key MUST NOT be written to extension storage. The master password MUST remain available as an unlock method. + +#### Scenario: Fingerprint unlock + +- **GIVEN** a vault owner who enrolled a fingerprint and whose extension is locked +- **WHEN** they choose the fingerprint unlock in the popup and touch the sensor +- **THEN** the extension MUST unlock and list the matching logins for the current site +- **AND** no extension storage area MUST contain the raw unlock key afterwards + +#### Scenario: A changed master password retires the credential + +- **GIVEN** a vault owner with an enrolled extension credential +- **WHEN** they change their master password in the web app +- **THEN** the login options MUST no longer offer the extension credential +- **AND** the popup MUST fall back to the master password form + +### Requirement: Extension credentials are visible and revocable in the web app + +The web app's passkey list MUST show extension credentials labelled as browser extension credentials, and the owner MUST be able to revoke one through `DELETE /api/v1/passkeys/{id}`. The web app MUST NOT offer an extension credential on its lock screen, and the extension MUST NOT be offered a web credential. + +#### Scenario: Revoking a lost laptop's extension credential + +- **GIVEN** a vault owner whose laptop with an enrolled extension credential is lost +- **WHEN** they revoke that credential in the passkey list of the web app +- **THEN** the extension on that laptop MUST no longer be able to unlock with it + +### Requirement: Biometric unlock is offered only where it works + +The extension MUST offer biometric unlock only when its page exposes WebAuthn, a user-verifying platform authenticator is available, and enrolment reports PRF support. Otherwise it MUST show the master password form only, without an error. + +#### Scenario: Browser without WebAuthn in extension pages + +- **GIVEN** a browser that refuses a WebAuthn ceremony from an extension page +- **WHEN** the vault owner opens the locked popup +- **THEN** the popup MUST show the master password form and no biometric option diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/tasks.md b/openspec/changes/clients-extension-unlock-lock-and-accounts/tasks.md new file mode 100644 index 000000000..7495b91fa --- /dev/null +++ b/openspec/changes/clients-extension-unlock-lock-and-accounts/tasks.md @@ -0,0 +1,29 @@ +# Tasks: extension biometric unlock, chosen lock delay, and several accounts + +## 1. Idle lock delay + +- [ ] 1.1 Add `GET /api/v1/extension/policy` returning `maxIdleMinutes` from app config `extension_max_idle_minutes` (default 240), with `#[NoAdminRequired]`, and a field for it in `SessionTimeoutSection.vue`. Verify: PHPUnit `ExtensionControllerTest` for the default and a set value; vitest for the admin field. +- [ ] 1.2 Add the popup settings view with the six delays, store `idleMinutes` per account, and clamp to `maxIdleMinutes` on every unlock in the worker. Verify: vitest asserts the stored value, the clamp, and that the timer uses the clamped value. + +## 2. Several accounts + +- [ ] 2.1 Move `api.js` storage to `keepiq.accounts` and `keepiq.activeAccountId`, with a one-time migration from `keepiq.config` and a limit of five accounts. Verify: vitest upgrades a stored old config into one account and refuses a sixth pairing. +- [ ] 2.2 Refactor `vault.js` to per-account key state and timers; OS lock and worker restart clear all accounts. Verify: vitest unlocks two accounts, lets one timer expire, and asserts only that account is locked. +- [ ] 2.3 Key the worker's blob cache by account, carry the account id in match, fill and save messages, and refuse a fill for a secret id from another account's match. Verify: vitest asserts the refusal and that a capture is saved to the active account. +- [ ] 2.4 Add the account switcher to the popup header (active account, lock state per account, add and unpair). Verify: vitest renders the switcher for three accounts and switches the active one. + +## 3. Biometric unlock + +- [ ] 3.1 Add `client_kind` and `rp_id` to `keepiq_passkey_credentials` with a migration and a `` bump; accept them in `PasskeyService::enroll()` and filter `loginOptions()` by `client` and `rpId`. Verify: PHPUnit `PasskeyServiceTest` asserts the web app never receives an extension credential and the reverse. +- [ ] 3.2 Re-export `deriveUnlockKeyRaw`, `decryptPrivateKeyWithRawKey` and the PRF helpers through `browser-extension/src/crypto/index.js`, and add a worker `unlock-raw` message that imports the key from a raw unlock key. Verify: vitest round trip: wrap a raw key, unwrap it, unlock the worker, decrypt a test secret. +- [ ] 3.3 Add the extension unlock window for enrolment and unlock (D3), opened with `chrome.windows.create`, with feature detection (D5). Verify: Playwright with the unpacked Chrome build and a virtual authenticator with PRF: a vault owner enrols, locks, and unlocks with the authenticator. +- [ ] 3.4 Label extension credentials in `src/components/PasskeyManager.vue` and let the owner revoke them there. Verify: vitest renders an extension credential with its label and calls the delete route. +- [ ] 3.5 Check which browsers pass the feature detection and record the result in `browser-extension/README.md`. Verify: manual check on current Chrome, Edge and Firefox. + +## Acceptance criteria + +- A user can pick an idle lock delay in the popup, and the extension never uses a delay above the administrator's maximum. +- A user can pair up to five accounts, switch between them from the popup header, and each account locks on its own timer. +- A fill request can never use a secret from an account other than the one that produced the match. +- Where the browser supports it, a user can unlock the extension with a fingerprint or face through a PRF passkey, and the master password always remains available. +- The server stores only the PRF-wrapped unlock key for an extension credential, and the web app lists and revokes it. diff --git a/openspec/changes/clients-offline-edits/.openspec.yaml b/openspec/changes/clients-offline-edits/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/clients-offline-edits/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/clients-offline-edits/design.md b/openspec/changes/clients-offline-edits/design.md new file mode 100644 index 000000000..aa3a6eb3e --- /dev/null +++ b/openspec/changes/clients-offline-edits/design.md @@ -0,0 +1,93 @@ +# Design: offline edit queue + +## Context + +Code at development `4c214a9d`: + +- `src/store/modules/offline.js` owns the offline state. `syncNow()` fetches `GET /api/v1/offline/manifest` (`lib/Controller/OfflineController.php`, built by `lib/Service/OfflineManifestService.php:88`), seals it with `encryptSnapshot()` (`src/offline/snapshot.js:30`, index fields encrypted with the raw unlock key through `encryptMetadata`) and writes it to IndexedDB (`src/offline/cache.js:62`). `unlockOffline()` unlocks from the cached suite envelope. The lock hook keeps the at-rest snapshot and clears only the in-memory view; `evict()` purges it (called on suite rotation from `src/store/modules/encryptionSuite.js:287` and `:1211`). +- `readOnly` is `servedFromCache` (the `readOnly` getter in `offline.js`); `src/views/SecretList.vue:705` disables writes with it, and `src/App.vue:76` shows the stale-data banner. +- Online edits: `PUT /api/v1/secrets/{id}` (`lib/Controller/SecretController.php:294`) takes ciphertext `key`, `login` and `additionalFields` plus plain `name`, `url`, `typeId`, `folderId`, with no precondition. It answers 423 during a suite migration write lock. `DELETE` is `:344`. +- Shared edits: `src/store/modules/share.js` fetches the write context (`GET /api/v1/secrets/{id}/write-context`, `ShareController::writeContext()`), encrypts the value for each recipient certificate it receives, and calls `PUT /api/v1/secrets/{sourceId}/sync` (`syncAsTeamWriter`). The server authorizes the fan-out on the effective grade (`lib/Service/ShareSyncService.php:167`). +- The web app's own-certificate encryption is `rsaEncrypt` from `src/crypto/rsa.js`, chunked at the RSA-4096 OAEP limit; the hybrid construction (AES-256-GCM content key wrapped with RSA-OAEP) exists in `src/crypto/emergencyEnvelope.js:52`. +- Admin setting `offline_cache_enabled` lives in `lib/Service/AdminSettingsService.php:199` and `:353`, with the UI in `src/components/settings/OfflineCacheSection.vue`. + +## Goals / Non-Goals + +**Goals:** + +- Create, edit, move and delete secrets while offline, with the change visible offline at once. +- Replay every change through the same online code paths, so the server rules (grades, write locks, policies) apply at sync time. +- Never send recipient ciphertext computed from a stale snapshot. +- Never overwrite a newer server change without the user choosing to. + +**Non-Goals:** + +- Offline sharing, unsharing, link shares, sends, team-folder membership, folder create or delete, and attachments. They stay online-only. +- Offline edits in the browser extension or the CLI. +- Automatic merging of two changed versions field by field. +- Background sync while the vault is locked. Replay needs the private key. + +## Decisions + +### D1: Queue what an online save would send, sealed to the owner + +Each queue entry holds: an id, the operation (`create`, `update`, `delete`), the secret id (a client-made UUID for a create), `baseUpdatedAt` (the snapshot's `updatedAt` for that secret), the time queued, the owner's suite id, and a sealed body. The sealed body holds the same `key`, `login` and `additionalFields` ciphertext an online save would send (RSA-OAEP to the owner's own certificate, chunked as today) plus the index fields `name`, `url`, `typeId` and `folderId`, the whole body wrapped in the hybrid envelope from `emergencyEnvelope.js` to the owner's own certificate. The hybrid envelope avoids the RSA chunk limit for long additional fields. + +Sealing to the certificate, not to the unlock key, means a routine master password change on another device does not strand the queue: the private key is the same, only its password wrapping changes. + +Alternative considered: sealing with the raw unlock key, like the snapshot's metadata. Rejected: the unlock key changes with the master password, and the queue must survive that. + +### D2: The fan-out happens at sync time only + +The queue never holds recipient ciphertext. At replay the web app decrypts the entry with the in-memory private key and calls the existing online action: create, update or delete of the owner's row. For a secret with recipients it then asks for the write context now, gets the current recipient list and certificates now, encrypts for each, and calls `PUT /api/v1/secrets/{sourceId}/sync`, exactly as an online edit does. A recipient added or removed while the user was offline is therefore handled correctly, which is the reason the offline cache spec gave for staying read-only. + +### D3: A server precondition catches concurrent changes + +`PUT` and `DELETE /api/v1/secrets/{id}` accept an optional `baseUpdatedAt`. When given and different from the stored `updatedAt`, the server changes nothing and answers `409 Conflict` with the current row (ciphertext and index fields, as a normal read returns). Online clients that do not send it behave as today. + +On a 409 the replay stops for that secret and shows a conflict dialog with both versions decrypted in the browser: "Keep my offline change" (replayed again with the new `baseUpdatedAt`; the server's version stays in version history) or "Keep the server version" (the entry is dropped). An offline delete against a changed secret asks the same question. + +Alternative considered: a new integer revision column. Rejected for now: `updatedAt` already exists, and an offline edit is based on a snapshot minutes or hours old, so a same-second collision is not a practical risk. + +### D4: Coalescing and order + +Entries for one secret coalesce locally: repeated updates keep the earliest `baseUpdatedAt` and the latest body; a create followed by updates stays one create; a create followed by a delete removes both. Replay runs oldest first. A create is replayed before any entry that refers to its folder. + +### D5: Failure states are explicit + +A 403 (for example a write grade removed while offline) or a 404 marks the entry failed, shows it in a "Changes that could not sync" list, and lets the user copy their values (decrypted in the browser) or discard the entry. A 423 (suite migration in progress) or a network error leaves it queued for the next attempt. If the owner row saved but the recipient sync failed, the entry stays as "sync recipients" and retries only that step, which is idempotent. + +### D6: When the replay runs + +Replay starts when the browser is online and the vault is unlocked online, and again on the browser's `online` event while unlocked. The banner shows "N changes waiting to sync" until the queue is empty. Before logout, and before starting a suite rotation, the app asks the user to sync or discard the pending changes; a rotation cannot start while entries are pending. + +If the active suite on the server differs from the entries' suite (a rotation ran on another device), the app asks once for the previous master password to open the entries with the cached old envelope, then replays them under the new certificate, or lets the user discard them. + +### D7: Administrator control, off by default + +A new app config `offline_edits_enabled` (default `false`) sits next to `offline_cache_enabled` in `OfflineCacheSection.vue` and travels in the offline manifest, so an offline client knows the rule. When false, the offline view stays read-only as today; entries already queued still replay on the next online unlock. When offline caching itself is disabled, the next online unlock replays the queue first and then purges the cache and the queue together. + +Alternative considered: on by default. Rejected: existing deployments chose offline caching under a read-only promise, and an upgrade should not change that without an administrator's choice. + +## Security and zero-knowledge + +The server receives only what an online save sends: the owner-row ciphertext, the plain index fields, and at sync time the recipient ciphertext made with recipient certificates fetched at that moment (ADR-003). The master password never leaves the browser; the queue is opened with the in-memory private key. + +At rest in IndexedDB: entries whose body is sealed to the owner's certificate (values and index fields). Plain in each entry: the entry id, the operation, the secret id, `baseUpdatedAt`, the queue time and the suite id. None of these is secret; the snapshot already holds secret ids in plain. A stolen device yields no value or name without the private key, which needs the master password. + +Plaintext exists only in the unlocked page: while the user edits, and briefly at replay while the recipient fan-out is computed. + +## Risks / Trade-offs + +- **A user edits offline, then the owner removes their write grade.** The replay gets 403 and the entry is kept for the user to copy or discard; nothing is lost silently. +- **A long offline period meets many server changes.** Each conflict is a user decision. The dialog shows both versions side by side. +- **Two devices both offline edit the same secret.** The second to sync gets the 409 and decides. +- **Logout with pending changes.** The app warns first; a forced logout keeps the sealed queue for the next login on that browser. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests use the existing development secrets and a Playwright context switched offline. + +## Migration + +None: no table, no column. The new setting is an app config key with a default, so no `` bump is needed. The IndexedDB database gains a queue store in a new schema version of the offline cache database, created on first use. diff --git a/openspec/changes/clients-offline-edits/proposal.md b/openspec/changes/clients-offline-edits/proposal.md new file mode 100644 index 000000000..bec017e81 --- /dev/null +++ b/openspec/changes/clients-offline-edits/proposal.md @@ -0,0 +1,52 @@ +--- +kind: code +--- + +# Edit secrets offline and sync the changes when back online + +## Why + +Keepiq's offline cache lets a field worker read the vault with no network, but every edit needs the server. Someone who rotates a password on site, with no signal, has to remember the new value until they are back online. The offline cache recorded the write queue as a deliberate future change, not as a non-goal. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| clients-19 | Edit items while offline and have the changes sync when you are back online. | Offline mode reads only; edits need the server. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. The offline cache is read-only by design (`openspec/specs/offline-readonly-cache/spec.md`, requirement "Offline mode is strictly read-only"), with the stale-data banner at `src/App.vue:76` and the write guard at `src/views/SecretList.vue:705`. The same spec gives the reason at `:128`: per-recipient share fan-out and sync-on-update re-encrypt against each recipient's current certificate and cannot be safely replayed from a stale snapshot. This change answers that reason: the queue never stores recipient ciphertext; the fan-out is computed at sync time, against certificates fetched at sync time. + +### Demand + +- featureRequest: https://community.bitwarden.com/t/offline-editing-management-of-writeable-vault-items/107 + +### Competitors rated yes + +- 1Password: "https://support.1password.com/sync/ : in the apps data is cached locally so you can view and edit it without an internet connection, and changes reach other devices when you next go online." + +## What Changes + +- When an administrator enables offline edits, a user reading from the offline cache can create, edit, move and delete secrets. Each change goes into a local queue in IndexedDB, sealed to the user's own certificate. +- The offline vault view shows queued changes on top of the cached data, marked "Not synced yet". +- When the browser is online and the vault is unlocked, the web app replays the queue through the normal online paths. For a shared or team-folder secret it fetches the current recipients and certificates at that moment and runs the usual sync-on-update fan-out. +- The server gets an optional `baseUpdatedAt` precondition on `PUT` and `DELETE /api/v1/secrets/{id}`. A changed secret answers `409 Conflict`, and the user chooses to keep their offline version or the server's. +- Sharing, unsharing, link shares, sends, team-folder membership, folders and attachments stay online-only. +- The requirement "Offline mode is strictly read-only" is removed from `offline-readonly-cache` and replaced by the queue's own requirements, including the rule that sharing stays online-only. + +## Capabilities + +### New Capabilities + +- `offline-edit-queue`: the offline change queue, its encryption at rest, replay with a fresh fan-out, conflict handling and administrator control. + +### Modified Capabilities + +- `offline-readonly-cache`: removes the strictly read-only requirement, which the queue supersedes. + +## Impact + +- **Backend**: `SecretController::update()` and `destroy()` accept `baseUpdatedAt` and answer 409 with the current row when it differs; a new admin config key `offline_edits_enabled`, exposed with `offline_cache_enabled` in the admin settings and the offline manifest. +- **Frontend**: `src/offline/` gains a queue store; `src/store/modules/offline.js` gains replay; the secret create and edit dialogs, `SecretList.vue` and the stale-data banner learn the queued state; a conflict dialog; `OfflineCacheSection.vue` gets the new switch. +- **Database**: none. No table, no column, no `` bump; the new setting is app config. +- **Security**: queued values are the same ciphertext an online save sends; index fields are sealed to the owner's certificate; recipient ciphertext is only ever produced at sync time. +- **Cross-app**: none. diff --git a/openspec/changes/clients-offline-edits/specs/offline-edit-queue/spec.md b/openspec/changes/clients-offline-edits/specs/offline-edit-queue/spec.md new file mode 100644 index 000000000..a0b867548 --- /dev/null +++ b/openspec/changes/clients-offline-edits/specs/offline-edit-queue/spec.md @@ -0,0 +1,86 @@ +## ADDED Requirements + +### Requirement: Offline changes go into a sealed local queue + +When the administrator setting `offline_edits_enabled` is true and the vault is served from the offline cache, the web app MUST let the user create, edit, move and delete secrets, and MUST store each change as a queue entry in IndexedDB. Each entry's values and index fields (`name`, `url`, `typeId`, `folderId`) MUST be sealed to the owner's own certificate; only the entry id, the operation, the secret id, `baseUpdatedAt`, the queue time and the suite id MAY be stored in plain. The vault view MUST show queued changes marked as not synced. + +#### Scenario: A field worker rotates a password with no signal + +- **GIVEN** a vault owner reading their vault offline with offline edits enabled +- **WHEN** they edit the password of the secret "Pump station router" in the secret list at /secrets and save +- **THEN** the secret MUST show the new value marked "Not synced yet" +- **AND** the IndexedDB queue MUST hold one entry whose stored form contains neither the new password nor the name "Pump station router" in plain + +#### Scenario: Offline edits disabled keeps the cache read-only + +- **GIVEN** offline edits are disabled by the administrator +- **WHEN** a vault owner reading offline tries to edit a secret +- **THEN** the action MUST be prevented with an explanation that Keepiq is read-only offline + +### Requirement: Sharing and membership actions stay online-only + +The web app MUST keep sharing, unsharing, link shares, sends, team-folder membership, folder create and delete, and attachment actions unavailable while the vault is served from the offline cache, whatever the offline edits setting, and MUST explain why. + +#### Scenario: Share is disabled offline + +- **GIVEN** a vault owner reading offline with offline edits enabled +- **WHEN** they open the share dialog for a secret +- **THEN** the share action MUST be disabled with an explanation that sharing needs a connection + +### Requirement: Replay runs through the online paths with a fresh fan-out + +When the browser is online and the vault is unlocked, the web app MUST replay queued entries oldest first through the same create, update and delete actions an online edit uses. For a secret with recipients it MUST fetch the write context at replay time and encrypt the new value for each recipient certificate returned at that moment. The queue MUST NOT contain, and replay MUST NOT send, recipient ciphertext made before the replay. + +#### Scenario: A recipient added while offline receives the change + +- **GIVEN** a vault owner who edited a shared secret offline, and a second recipient added to that secret by a co-owner in the meantime +- **WHEN** the owner reconnects and unlocks +- **THEN** the replay MUST encrypt the new value for both the original and the new recipient using certificates fetched at replay time +- **AND** both recipients MUST see the new value + +### Requirement: Concurrent server changes are never overwritten silently + +`PUT /api/v1/secrets/{id}` and `DELETE /api/v1/secrets/{id}` MUST accept an optional `baseUpdatedAt`; when it is present and differs from the stored `updatedAt`, the server MUST change nothing and answer `409 Conflict` with the current row. On a 409 the web app MUST stop that entry and let the user keep their offline version or the server version. + +#### Scenario: The secret changed online meanwhile + +- **GIVEN** a vault owner who edited a secret offline, and the same secret changed from another browser after the snapshot was taken +- **WHEN** the owner reconnects and the replay sends the update with the snapshot's `baseUpdatedAt` +- **THEN** the server MUST answer 409 and leave the secret unchanged +- **AND** the web app MUST show both versions and apply the owner's choice + +#### Scenario: Clients without the precondition are unaffected + +- **GIVEN** an online client that sends `PUT /api/v1/secrets/{id}` without `baseUpdatedAt` +- **WHEN** the request is processed +- **THEN** the server MUST update the secret as it did before this change + +### Requirement: Failed entries are kept, never dropped silently + +A replay answered with 403 or 404 MUST move the entry to a failed list where the user can copy their values (decrypted in the browser) or discard the entry. A 423 or a network error MUST keep the entry queued. An entry whose owner row saved but whose recipient sync failed MUST retry only the sync step. + +#### Scenario: Write grade removed while offline + +- **GIVEN** a team-folder member who edited a folder secret offline, and whose grade was lowered to read meanwhile +- **WHEN** the replay's sync request is refused with 403 +- **THEN** the entry MUST appear under "Changes that could not sync" with copy and discard actions + +### Requirement: Pending changes block logout and rotation + +The web app MUST warn before logout while entries are pending, MUST refuse to start a suite rotation while entries are pending, and after a rotation made on another device MUST ask once for the previous master password to reopen the entries with the cached old suite envelope, or let the user discard them. + +#### Scenario: Rotation waits for the queue + +- **GIVEN** a vault owner with two pending offline changes +- **WHEN** they start a compromise recovery rotation +- **THEN** the web app MUST refuse and ask them to sync or discard the changes first + +### Requirement: Administrators control offline edits + +The system MUST provide an admin setting `offline_edits_enabled`, default false, shown next to `offline_cache_enabled` and carried in the offline manifest. When offline caching is disabled, the next online unlock MUST replay the queue before purging the cache and the queue. + +#### Scenario: Administrator enables offline edits + +- **GIVEN** an administrator on the offline cache section of the Keepiq admin settings +- **WHEN** they turn on offline edits and a user next syncs online +- **THEN** the offline manifest MUST report `offlineEditsEnabled` true and the user's offline view MUST allow edits diff --git a/openspec/changes/clients-offline-edits/specs/offline-readonly-cache/spec.md b/openspec/changes/clients-offline-edits/specs/offline-readonly-cache/spec.md new file mode 100644 index 000000000..7e306082f --- /dev/null +++ b/openspec/changes/clients-offline-edits/specs/offline-readonly-cache/spec.md @@ -0,0 +1,7 @@ +## REMOVED Requirements + +### Requirement: Offline mode is strictly read-only + +**Reason**: Superseded by the `offline-edit-queue` capability. With offline edits disabled (the default) the offline view stays read-only exactly as this requirement said; with them enabled, secret edits are queued and replayed with a fresh fan-out. + +**Migration**: The read-only behaviour for sharing, link shares, sends, team-folder membership, folders and attachments moves to the requirement "Sharing and membership actions stay online-only" in `offline-edit-queue`; the read-only behaviour for secret edits when the setting is off moves to "Offline changes go into a sealed local queue". diff --git a/openspec/changes/clients-offline-edits/tasks.md b/openspec/changes/clients-offline-edits/tasks.md new file mode 100644 index 000000000..4d49da85e --- /dev/null +++ b/openspec/changes/clients-offline-edits/tasks.md @@ -0,0 +1,38 @@ +# Tasks: offline edit queue + +## 1. Server + +- [ ] 1.1 Accept an optional `baseUpdatedAt` on `SecretController::update()` and `destroy()`; when it differs from the stored `updatedAt`, change nothing and answer 409 with the current row. Verify: PHPUnit `SecretControllerTest` covers a match, a mismatch, and an absent value behaving as today. +- [ ] 1.2 Add the `offline_edits_enabled` app config (default false) to `AdminSettingsService` and to the offline manifest response. Verify: PHPUnit asserts the default, a set value, and the manifest field. + +## 2. Queue at rest + +- [ ] 2.1 Add a queue store to the offline IndexedDB database (new schema version) with entries sealed to the owner's certificate through the hybrid envelope (D1). Verify: vitest asserts no stored entry contains a plain value, name or URL, and that a round trip opens with the private key. +- [ ] 2.2 Add coalescing (D4): update chains, create then update, create then delete. Verify: vitest for each chain. + +## 3. Offline editing + +- [ ] 3.1 When offline and `offline_edits_enabled` is true, enable create, edit, move and delete in `SecretList.vue` and the secret dialogs, write to the queue, and show queued changes on the cached view marked "Not synced yet". Keep share, link share, send, team-folder, folder and attachment actions disabled with an explanation. Verify: vitest for the enabled and disabled action sets. +- [ ] 3.2 Show "N changes waiting to sync" in the stale-data banner and a warning before logout while entries are pending. Verify: vitest renders both states. + +## 4. Replay + +- [ ] 4.1 Replay the queue oldest first when online and unlocked, through the existing store actions; for a secret with recipients fetch the write context at replay time and run the normal sync fan-out. Verify: vitest with a mocked API asserts the certificates used are the ones returned at replay, not any cached value. +- [ ] 4.2 Handle outcomes (D5): 409 opens the conflict dialog, 403 and 404 move the entry to the failed list, 423 and network errors keep it queued, and a failed recipient sync retries only the sync step. Verify: vitest for each outcome. +- [ ] 4.3 Add the conflict dialog in `src/dialogs/` (keep mine, keep the server's) and the failed-changes list with copy and discard. Verify: vitest for both choices and for copy. +- [ ] 4.4 Block a suite rotation while entries are pending, and after a rotation elsewhere ask once for the previous master password to reopen entries under the cached old envelope. Verify: vitest for the block and the reopen path. + +## 5. Administrator and end-to-end + +- [ ] 5.1 Add the offline edits switch to `OfflineCacheSection.vue`. Verify: vitest for the switch and its save call. +- [ ] 5.2 Add a Playwright flow: a vault owner unlocks online, goes offline, edits a secret shared with a second user, goes online, and the second user sees the new value. Verify: the Playwright spec passes in the E2E job. +- [ ] 5.3 Add a Playwright flow for a conflict: the same secret changes online from a second browser while the first is offline; the first sees the conflict dialog on reconnect. Verify: the Playwright spec passes in the E2E job. + +## Acceptance criteria + +- With offline edits enabled, a user can create, edit, move and delete secrets offline and sees the changes at once, marked as not synced. +- The queue at rest holds no plain value, name or URL. +- Replay uses the online code paths, and recipient ciphertext is made only at replay time with certificates fetched at replay time. +- A secret changed on the server since the snapshot is never overwritten without the user's choice. +- Sharing, link shares, sends, team-folder membership, folders and attachments stay unavailable offline. +- With offline edits disabled (the default), the offline view behaves exactly as the read-only cache does today. diff --git a/openspec/changes/clients-ssh-agent/.openspec.yaml b/openspec/changes/clients-ssh-agent/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/clients-ssh-agent/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/clients-ssh-agent/design.md b/openspec/changes/clients-ssh-agent/design.md new file mode 100644 index 000000000..a6f089e99 --- /dev/null +++ b/openspec/changes/clients-ssh-agent/design.md @@ -0,0 +1,87 @@ +# Design: SSH agent in the Keepiq CLI + +## Context + +Code at development `4c214a9d`: + +- `cli/main.go:33` dispatches subcommands (`login`, `list`, `show`, `get`, `copy`, `ci`, `completion`). `cli/main.go:133` `openHumanSession()` loads the stored pairing, fetches the active suite, unwraps the private key with the master password (`dcrypto.UnwrapPrivateKey`, `cli/internal/crypto/crypto.go:89`), parses it (`:121`) and checks it against the certificate (`:177`). `cli/main.go:119` `promptSecret()` reads without echo. +- `cli/internal/client/client.go:96` `ListSecrets()` fetches every secret row (ciphertext plus index fields) and `:107` `GetSecret()` one row. The `Secret` struct (`:70`) carries `TypeID`, `FolderID`, `Key` and `AdditionalFields`. There is no secret-type lookup yet. +- `cli/internal/crypto/crypto.go:139` `DecryptField()` decrypts one RSA-OAEP chunked field. +- `cli/go.mod` declares `go 1.22` and no dependencies; `cli/README.md` and `.github/workflows/cli-release.yml` call the CLI stdlib-only. `cli-release.yml` runs `go vet` and `go test` and cross-compiles six targets. +- `ssh_key` secrets store the OpenSSH private key in the `key` field and the public key in the encrypted additional fields (`src/cxf/cxf.js:355`). The seeded development key in `lib/Repair/SeedDevelopmentSecrets.php:162` is a truncated placeholder, not a usable key. +- `openspec/specs/keepiq-cli/spec.md` requires read-only v1 and in-process decryption; the session cache is a documented follow-up. + +## Goals / Non-Goals + +**Goals:** + +- `ssh`, `git` and `scp` sign with keys held in the vault, through the standard `SSH_AUTH_SOCK` interface. +- Decrypted private keys never touch the disk and live only while the agent is unlocked. +- Optional per-use confirmation and an idle lock. + +**Non-Goals:** + +- Windows. A named-pipe listener needs another dependency and its own testing; Windows users can run the agent in WSL until a follow-up change. +- Passphrase-protected OpenSSH keys. The agent skips them and names them; a later change can read a passphrase from an additional field. +- Adding keys to the vault through `ssh-add`. The CLI is read-only in v1. +- A server-side record of each signature. The CLI adds no backend route, as `keepiq-cli` requires. +- A desktop app, a GUI prompt of our own, or a mobile agent. + +## Decisions + +### D1: A subcommand of the existing CLI + +`keepiq ssh-agent [--socket ] [--confirm] [--idle ] [--folder ] [--locked]` runs in the foreground and prints `SSH_AUTH_SOCK=; export SSH_AUTH_SOCK;` for `eval`. The default socket is `$XDG_RUNTIME_DIR/keepiq/agent.sock` on Linux and `$TMPDIR/keepiq-/agent.sock` on macOS. Running it under a systemd user unit or a launchd agent is documented. + +Alternative considered: a separate `keepiq-agent` binary. Rejected: one binary already carries the pairing, the crypto and the release pipeline. + +### D2: Two vetted dependencies instead of a hand-written protocol + +The agent uses `golang.org/x/crypto/ssh` to parse OpenSSH private keys and sign, `golang.org/x/crypto/ssh/agent` to serve the protocol (`agent.ServeAgent` over a custom `agent.ExtendedAgent`), and `golang.org/x/sys/unix` for peer credentials on macOS. Both are Go project modules, pure Go, so the binary stays static. The README and the workflow comment drop the stdlib-only claim, and the test job adds `govulncheck ./...`. + +Alternative considered: implementing the agent protocol and the OpenSSH key format by hand to stay stdlib-only. Rejected: a key parser and a signing protocol are exactly the code that should come from a maintained, audited module. + +### D3: Unlock in process, two ways + +Started from a terminal, the agent prompts for the master password with the existing `promptSecret()` and calls `openHumanSession()`. Started with `--locked` (for a service manager), it holds no keys until the user runs `ssh-add -X`, which sends a password over the socket as the protocol's unlock request; the agent treats it as the master password. `ssh-add -x` locks it again. + +On unlock the agent looks up the `ssh_key` type id through `GET /api/v1/secret-types`, lists the user's secrets, keeps those of that type (and in `--folder`, if set), decrypts each `key` field, and parses it. A key that does not parse or is passphrase-protected is skipped and named on standard error. The public key is derived from the private key, so the encrypted additional fields need not be read. Each identity's comment is the secret name. + +### D4: Supported signatures + +Ed25519, ECDSA (P-256, P-384, P-521) and RSA with `rsa-sha2-256` and `rsa-sha2-512`. An RSA sign request without one of those flags (the SHA-1 `ssh-rsa` scheme) is refused. + +### D5: Socket safety + +The agent creates the socket directory with mode `0700` and the socket with `0600`, refuses to start if the directory exists with another owner or a wider mode, and refuses a connection whose peer uid differs from its own (`SO_PEERCRED` on Linux, `LOCAL_PEERCRED` on macOS). At start it disables core dumps (`RLIMIT_CORE` 0, and on Linux `PR_SET_DUMPABLE` 0). + +### D6: Confirmation and idle lock + +With `--confirm`, before each signature the agent runs the program in `SSH_ASKPASS` with `SSH_ASKPASS_PROMPT=confirm` and the key name, and signs only on exit status 0. If `SSH_ASKPASS` is unset, `--confirm` refuses to start rather than sign unconfirmed. With `--idle ` (default 60, 0 disables), the agent drops every decrypted key and the unwrapped suite key after that many minutes without a sign request, and answers as a locked agent until the next unlock. + +### D7: The vault is the only source + +Add-identity, remove-identity and remove-all requests answer with failure. `ssh-add -l` lists vault keys; `ssh-add some_key` fails with a message pointing to the vault. + +## Security and zero-knowledge + +The server's view does not change: the agent authenticates with the paired Nextcloud app password and fetches the suite envelope and secret ciphertext, exactly as `keepiq show` does. It never sends a master password, a derived key, a private key or a signature to the server (ADR-003). + +In the agent process, while unlocked: the RSA suite private key and the parsed SSH private keys. This is inherent to any SSH agent. They are never written to disk, core dumps are disabled, and the idle lock and `ssh-add -x` drop them. The master password is used once to unwrap the suite key and then released. + +Stored in plain text on disk: only the existing CLI pairing file (server URL, user, app password, mode `0600`). The socket carries sign requests from processes of the same user, which is the same trust boundary OpenSSH's own agent uses; D5 enforces it. + +## Risks / Trade-offs + +- **An unlocked agent signs for any process of the same user.** That is the SSH agent model. `--confirm` adds a per-use prompt for users who want it. +- **`ssh-add -X` sends the master password over the local socket.** The socket is owner-only and peer-checked; the alternative (a service manager that cannot prompt) would leave no way to unlock a background agent. +- **New dependencies add supply-chain surface.** Both are Go project modules, pinned in `go.sum`, and checked with `govulncheck` in CI. +- **Windows users wait.** Recorded as a non-goal with WSL as the workaround. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Go tests generate throwaway keys in the test process; no key is committed (gitleaks). The truncated seeded `ssh_key` in `SeedDevelopmentSecrets.php` stays as it is and is expected to be skipped by the agent. + +## Migration + +None: no table, no column, no `` bump. The CLI release (`cli-v*` tag) carries the new subcommand. diff --git a/openspec/changes/clients-ssh-agent/proposal.md b/openspec/changes/clients-ssh-agent/proposal.md new file mode 100644 index 000000000..8e5d2ae52 --- /dev/null +++ b/openspec/changes/clients-ssh-agent/proposal.md @@ -0,0 +1,55 @@ +--- +kind: code +--- + +# An SSH agent in the Keepiq command-line client + +## Why + +Keepiq stores SSH keys as a secret type, but nothing hands them to `ssh` or `git`. A developer has to reveal the private key, write it to a file and load it into another agent, which defeats the point of keeping it in the vault. The three competitors that rate yes all run an agent that serves vault keys directly. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| clients-13 | Sign in over SSH with keys kept in the vault through an SSH agent. | SSH keys can be stored as secrets, but nothing exposes them to an SSH agent. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. `ssh_key` exists as a seeded secret type (`lib/Repair/SeedSecretTypes.php:64`) and as a CXF mapping (`src/cxf/cxf.js:355`, private key in the `key` field, public key in the additional fields), but no code in `lib`, `src`, `cli` or `browser-extension` speaks the agent protocol. The decision places the agent in the existing Go CLI in `cli/`, which is a native binary already, so no desktop app is needed. The product records no native app (`openspec/specs/mobile-pwa/spec.md:11` and `:49`). + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 apps/desktop/desktop_native/core/src/ssh_agent/mod.rs, named_pipe_listener_stream.rs (Windows), peercred_unix_listener_stream.rs (Unix); bitwarden/server@v2026.9.1 src/Core/Vault/Enums/CipherType.cs:11 SSHKey Note: Desktop app runs an SSH agent serving SSH key items, with per-use approval." +- 1Password: "https://developer.1password.com/docs/ssh/agent/ : SSH Agent uses keys saved in 1Password, private key 'never even leaves the 1Password app'" +- Keeper: "https://docs.keeper.io/keeperpam/privileged-access-manager/ssh-agent : 'Keeper's built-in SSH agent' serves SSH keys stored in the vault (documented under KeeperPAM)" + +## What Changes + +- A new subcommand `keepiq ssh-agent` runs an OpenSSH-compatible agent on a Unix socket on Linux and macOS. +- The agent unlocks the vault in its own process (master password at the terminal, or through `ssh-add -X` when started locked), decrypts the user's `ssh_key` secrets in memory, and answers identity-list and sign requests. +- Optional per-use confirmation through `SSH_ASKPASS` (`--confirm`), an idle lock that drops every decrypted key (`--idle`), and a folder filter (`--folder`). +- The agent refuses to add or remove keys: the vault is the only source, matching the CLI's read-only v1. +- The CLI takes its first dependencies, `golang.org/x/crypto` (SSH key parsing, signing and the agent protocol) and `golang.org/x/sys` (peer credential checks), and CI adds `govulncheck`. +- Documentation for running the agent as a systemd user service or a launchd agent. + +## Capabilities + +### New Capabilities + +- `cli-ssh-agent`: an SSH agent in the Keepiq CLI that serves vault SSH keys with in-process decryption. + +### Modified Capabilities + +None. The `keepiq-cli` requirements stay as they are; this change adds a subcommand with its own capability. + +## Impact + +- **Backend**: none. The agent reads the same routes `keepiq list` and `keepiq show` read (`/api/v1/suites`, `/api/v1/secrets`, `/api/v1/secret-types`). +- **Frontend**: none. +- **Database**: none; no migration, no `` bump. +- **Security**: decrypted SSH private keys exist only in the agent's memory while it is unlocked; the socket is owner-only; the server sees nothing it does not see for `keepiq show`. +- **Cross-app**: none. +- **Release**: `cli-release.yml` builds the new subcommand for Linux and macOS; on Windows the subcommand reports that it is not supported yet. diff --git a/openspec/changes/clients-ssh-agent/specs/cli-ssh-agent/spec.md b/openspec/changes/clients-ssh-agent/specs/cli-ssh-agent/spec.md new file mode 100644 index 000000000..f974aea45 --- /dev/null +++ b/openspec/changes/clients-ssh-agent/specs/cli-ssh-agent/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: The CLI runs an SSH agent that serves vault SSH keys + +The CLI MUST provide `keepiq ssh-agent`, which serves the OpenSSH agent protocol on a Unix socket on Linux and macOS and prints the `SSH_AUTH_SOCK` export for that socket. Once unlocked it MUST offer every `ssh_key` secret the user owns (limited to one folder when `--folder` is given) whose private key parses without a passphrase, with the secret name as the identity comment, and MUST name each skipped key on standard error. + +#### Scenario: A developer clones over SSH with a vault key + +- **GIVEN** a developer whose vault holds an `ssh_key` secret named "GitHub deploy" and who runs `eval "$(keepiq ssh-agent)"` and enters their master password +- **WHEN** they run `git clone git@github.com:example/repo.git` +- **THEN** `ssh` MUST authenticate with the "GitHub deploy" key through the agent +- **AND** no file containing that private key MUST exist on disk + +#### Scenario: A passphrase-protected key is skipped + +- **GIVEN** a vault with one plain Ed25519 key and one passphrase-protected key +- **WHEN** the agent unlocks +- **THEN** `ssh-add -l` MUST list only the Ed25519 key +- **AND** standard error MUST name the skipped key + +### Requirement: Decryption happens only in the agent process + +The agent MUST unwrap the suite private key and decrypt SSH keys inside its own process, using the paired Nextcloud app password to fetch only ciphertext, and MUST NOT send the master password, a derived key, a private key or a signature to the server. It MUST NOT write any decrypted key to disk, and MUST disable core dumps at start. + +#### Scenario: The server sees only ciphertext reads + +- **GIVEN** an agent unlocking against a Keepiq server +- **WHEN** the requests the agent makes are recorded +- **THEN** they MUST be reads of `/api/v1/suites`, `/api/v1/secret-types` and `/api/v1/secrets` +- **AND** no request body MUST contain the master password or any key material + +### Requirement: The agent unlocks from a terminal or through ssh-add + +The agent MUST prompt for the master password at start when run from a terminal. When started with `--locked` it MUST hold no keys until an `ssh-add -X` unlock request supplies the master password, and `ssh-add -x` MUST lock it again. While locked it MUST answer identity requests with an empty list and refuse every sign request. + +#### Scenario: A service-managed agent is unlocked later + +- **GIVEN** an agent started with `--locked` by a systemd user unit +- **WHEN** the developer runs `ssh-add -X` and enters their master password +- **THEN** `ssh-add -l` MUST list their vault keys + +#### Scenario: Locking drops the keys + +- **GIVEN** an unlocked agent +- **WHEN** the developer runs `ssh-add -x` +- **THEN** a following sign request MUST be refused +- **AND** `ssh-add -l` MUST report no identities + +### Requirement: Only modern signature schemes + +The agent MUST sign with Ed25519, with ECDSA on P-256, P-384 and P-521, and with RSA only when the request carries the `rsa-sha2-256` or `rsa-sha2-512` flag. An RSA sign request without one of those flags MUST be refused. + +#### Scenario: A SHA-1 RSA request is refused + +- **GIVEN** an unlocked agent holding an RSA key +- **WHEN** a client asks for an `ssh-rsa` signature without a SHA-2 flag +- **THEN** the agent MUST answer with failure and produce no signature + +### Requirement: The socket is private to the user + +The agent MUST create its socket directory with mode `0700` and the socket with mode `0600`, MUST refuse to start when the directory exists with another owner or a wider mode, and MUST close any connection whose peer uid differs from its own. + +#### Scenario: Another local user is refused + +- **GIVEN** an agent run by user `alice` +- **WHEN** a process of user `bob` connects to the socket +- **THEN** the agent MUST close the connection without answering any request + +### Requirement: Optional confirmation and idle lock + +With `--confirm` the agent MUST run the program in `SSH_ASKPASS` with `SSH_ASKPASS_PROMPT=confirm` and the key name before each signature and sign only on exit status 0; it MUST refuse to start with `--confirm` when `SSH_ASKPASS` is unset. With `--idle ` (default 60, 0 disables) it MUST drop every decrypted key and the suite key after that many minutes without a sign request. + +#### Scenario: A denied confirmation blocks the signature + +- **GIVEN** an agent started with `--confirm` +- **WHEN** `ssh` asks for a signature and the user dismisses the confirmation dialog +- **THEN** the agent MUST refuse the signature + +#### Scenario: The idle lock clears keys + +- **GIVEN** an agent started with `--idle 30` and no sign request for 30 minutes +- **WHEN** `ssh` next asks for a signature +- **THEN** the agent MUST refuse it as locked until the developer unlocks again + +### Requirement: The vault is the only key source + +The agent MUST answer add-identity, remove-identity and remove-all-identities requests with failure. + +#### Scenario: ssh-add cannot add a local key + +- **GIVEN** an unlocked agent +- **WHEN** the developer runs `ssh-add ~/.ssh/id_ed25519` +- **THEN** the agent MUST refuse the request and the key MUST NOT be listed diff --git a/openspec/changes/clients-ssh-agent/tasks.md b/openspec/changes/clients-ssh-agent/tasks.md new file mode 100644 index 000000000..32ff524a4 --- /dev/null +++ b/openspec/changes/clients-ssh-agent/tasks.md @@ -0,0 +1,31 @@ +# Tasks: SSH agent in the Keepiq CLI + +## 1. Dependencies and plumbing + +- [ ] 1.1 Add `golang.org/x/crypto` and `golang.org/x/sys` to `cli/go.mod`, add `govulncheck ./...` to the test job in `cli-release.yml`, and update the stdlib-only wording in `cli/README.md` and the workflow comment. Verify: `go vet ./...`, `go test ./...` and `govulncheck ./...` pass in the workflow. +- [ ] 1.2 Add `SecretTypes()` to `cli/internal/client/client.go` (`GET /api/v1/secret-types`) and a helper that returns the `ssh_key` type id. Verify: a Go unit test with an `httptest` server. + +## 2. Agent core + +- [ ] 2.1 Add `cli/sshagent/` with a keyring that loads the user's `ssh_key` secrets (optionally one folder), decrypts and parses each key, skips and names passphrase-protected or unparsable ones, and derives the public keys. Verify: a Go test with generated Ed25519, ECDSA and RSA keys plus one passphrase-protected key. +- [ ] 2.2 Implement `agent.ExtendedAgent`: list, sign (Ed25519, ECDSA, RSA with SHA-2 flags only), lock and unlock (master password), and failure for add and remove. Verify: a Go test drives it through `agent.NewClient` over `net.Pipe`, verifies each signature, and asserts the SHA-1 RSA refusal and the add refusal. +- [ ] 2.3 Add the idle lock (D6) that drops every decrypted key and the suite key. Verify: a Go test with an injected clock asserts a locked answer after the idle period. +- [ ] 2.4 Add `--confirm` through `SSH_ASKPASS` with `SSH_ASKPASS_PROMPT=confirm`, refusing to start without `SSH_ASKPASS`. Verify: a Go test with stub askpass scripts exiting 0 and 1. + +## 3. Socket and command + +- [ ] 3.1 Add the socket listener with directory `0700`, socket `0600`, the owner and mode check, and the peer uid check on Linux and macOS; disable core dumps at start. Verify: Go tests for a wrong directory mode and a foreign peer uid (Linux test in CI). +- [ ] 3.2 Add the `ssh-agent` subcommand (`--socket`, `--confirm`, `--idle`, `--folder`, `--locked`), the `SSH_AUTH_SOCK` output, usage and completion entries, and a "not supported on Windows yet" message behind a build tag. Verify: a Go test for flag parsing; `GOOS=windows go build` succeeds. +- [ ] 3.3 Add an integration test that starts a throwaway `sshd` on localhost with a generated key in CI, loads the key into a test vault double, and runs `ssh -o IdentityAgent=` to it. Verify: the test passes in the CLI workflow on Linux. + +## 4. Documentation + +- [ ] 4.1 Document the agent in `cli/README.md`: start, `eval`, `ssh-add -X` unlock, `--confirm`, `--idle`, a systemd user unit and a launchd plist, and the Windows status. Verify: manual review with the writing skill, and a manual `git clone` over SSH on macOS and Linux using the agent. + +## Acceptance criteria + +- `keepiq ssh-agent` serves the user's vault SSH keys to `ssh` and `git` on Linux and macOS through `SSH_AUTH_SOCK`. +- Keys are decrypted only in the agent process; nothing decrypted is written to disk and the server receives no key material. +- The socket is reachable only by the same user; a foreign peer is refused. +- `--confirm` asks before each signature and fails closed; `--idle` drops all decrypted keys after the idle period. +- `ssh-add` cannot add or remove keys through the agent. diff --git a/openspec/changes/crypto-item-reprompt/design.md b/openspec/changes/crypto-item-reprompt/design.md new file mode 100644 index 000000000..2dd542baf --- /dev/null +++ b/openspec/changes/crypto-item-reprompt/design.md @@ -0,0 +1,49 @@ +# Design: ask for the master password again before a sensitive item is shown or filled + +## Context + +At development `4c214a9d`: + +- `src/crypto/reauth.js:117` `verifyMasterPassword(encryptedPrivateKey, masterPassword)` decrypts the stored private-key envelope with the entered password and returns true or false, never replacing the session key; its header says the control is advisory against a tampered client. +- `src/dialogs/ExportDialog.vue:130` uses it before a plaintext export. +- `src/components/SecretDetailSidebar.vue:205-208` reveals the value through `PasswordField` with `:resolve="resolveKey"` (`:1487`); copy buttons sit at `:187`, `:311`, `:367`, `:409`, `:529`; edit opens at `:1498`. +- `src/components/SecretListItem.vue:93` has a copy button on each list row. +- `browser-extension/src/lib/vault.js:49` `unlock()` derives the key with `decryptPrivateKey(suite.privateKey, masterPassword)`; `service-worker.js:113-125` `doFill()` decrypts and fills. +- Share copies are separate rows created by the sharing services; `lib/Service/ShareSyncService.php:317-345` syncs only the encrypted blobs. + +## Goals / Non-Goals + +**Goals** +- A person sitting at an unlocked, unattended session cannot show, copy or fill a flagged item without the master password. + +**Non-Goals** +- A remember-for-a-while window. See D3. +- Server-side enforcement. The server cannot check a master password it never sees (ADR-003). +- Reprompting on passkey use from the extension's WebAuthn provider in this change; the extension passkey flow keeps its own user-verification step. + +## Decisions + +**D1. The flag is plain metadata on the holder's row.** `reprompt` boolean, default false. It says nothing about the value. A new share copy takes the owner's value; afterwards each holder controls their own row. Alternative: inside the encrypted additional fields. Rejected: the list and the extension must know an item is flagged before decrypting it. + +**D2. One guard for every reveal path.** A composable `useReprompt(secret)` returns a function that resolves when the item is not flagged, or after `RepromptDialog.vue` got a password that `verifyMasterPassword()` accepts. `resolveKey`, every `CopyButton` on a flagged item, edit, clone, print and QR call it. A unit test enumerates the reveal paths so a new one cannot skip the guard unnoticed. + +**D3. Every action asks.** No grace period, the same as Bitwarden's per-item re-prompt. A user who wants fewer prompts leaves the flag off. Alternative: a window of a few minutes. Rejected: it turns "ask before this item" into "ask once", which is what unlocking already does. + +**D4. The extension checks inside the popup.** The popup asks for the master password and verifies it against the suite envelope the extension already fetched at unlock, then fills. Nothing new is fetched. + +## Security and zero-knowledge + +The master password is typed into the web app or the extension popup, checked locally against the encrypted private-key envelope and discarded. It never leaves the client, as ADR-003 requires. The flag is not sensitive and is stored in plain text. The dialog's help text states that this protects an unlocked screen, not a modified client. + +## Risks / Trade-offs + +- Frequent prompts on a flagged item used daily. The user chooses which items to flag. +- A missed reveal path leaves a hole. The enumeration test in D2 is the guard. + +## Seed data + +Keepiq owns its tables (keepiq ADR-001) and has no OpenRegister register. The dev fixture vault gets one flagged login, so the prompt appears in the e2e flows. + +## Migration + +One migration after `Version001000Date20260908000000`: `reprompt` (boolean, default false) on `keepiq_secrets`. `` bumps. diff --git a/openspec/changes/crypto-item-reprompt/proposal.md b/openspec/changes/crypto-item-reprompt/proposal.md new file mode 100644 index 000000000..c5cfce57b --- /dev/null +++ b/openspec/changes/crypto-item-reprompt/proposal.md @@ -0,0 +1,50 @@ +--- +kind: code +--- + +# Ask for the master password again before a sensitive item is shown or filled + +## Why + +Once the vault is unlocked, every secret in it can be shown, copied and filled until the session times out or is locked. For most logins that is right. For a few (the domain admin account, the bank's payment login, the break-glass root key) a user wants a second look at who is at the keyboard, the way the plaintext export already asks: it verifies the master password again before writing a plain file (`src/dialogs/ExportDialog.vue:130`, `src/crypto/reauth.js:117` `verifyMasterPassword`). Nothing else uses that check. The reveal field (`src/components/SecretDetailSidebar.vue:205-208` `resolveKey`), the copy buttons (`:187`, `:311`, `:367`, `:409`, `:529`, `src/components/SecretListItem.vue:93`) and the extension's fill (`browser-extension/src/background/service-worker.js:113-125`) act at once. + +### Matrix rows (keepiq `openspec/parity/capabilities.json`) + +| row | capability | Keepiq today | +|---|---|---| +| `crypto-20` | Ask for the master password again before a sensitive item is shown or filled. | `no`: master password re-entry guards the plaintext export only | + +### Demand + +- Feature request, https://community.bitwarden.com/t/require-master-password-re-prompt-for-some-items/41 + +### Competitors rated yes + +- Bitwarden: "src/Core/Vault/Entities/Cipher.cs:27 Reprompt; ... additional-options-section.component.ts:50 reprompt toggle; libs/vault/src/services/password-reprompt.service.ts:42 passwordRepromptCheck(); apps/browser/src/autofill/services/autofill.service.ts:53 openVaultItemPasswordRepromptPopout before fill Note: Per-item master password re-prompt, set in the item form and enforced before view, copy and autofill." +- Passbolt: "getPassphraseService.js:35 passphrase requested for every decrypt unless the user ticked remember; ... InputPassphrase.js:210 remember-me durations Note: By default the passphrase is asked before any secret is shown, copied or filled; users can opt to remember it for a set time. It is global, not a per-item flag." + +## What Changes + +- **A per-item switch.** The create and edit dialogs get "Ask for my master password before showing or filling this item". It is stored as a plain flag on the holder's row. A share copy starts with the owner's setting. +- **Enforced in the web app.** For a flagged item, revealing the value, copying the value or username, opening the edit dialog, cloning, printing and showing a QR code first ask for the master password and check it with `verifyMasterPassword()`. The list shows a lock marker on flagged items. +- **Enforced in the extension.** Filling a flagged item from the popup first asks for the master password inside the popup and checks it against the vault key. +- **No grace period.** Each action asks again. The check is client-side and says so, like the export check. + +## Capabilities + +### New Capabilities + +- `item-master-password-reprompt`: a per-item flag that makes the web app and the browser extension verify the master password before the item's value is shown, copied or filled. + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Backend**: a boolean column `reprompt` on `keepiq_secrets`, accepted on create and update and copied onto new share copies. +- **Frontend**: a checkbox in `SecretCreateDialog.vue` and `SecretEditDialog.vue`, a shared `useReprompt()` guard wrapping `resolveKey`, `CopyButton` and the edit, clone, print and QR actions, a lock marker in `SecretListItem.vue`, a `RepromptDialog.vue` under `src/dialogs/`. +- **Browser extension**: a master password prompt in the popup before `doFill()` for flagged items. +- **Database**: one migration; `` bump. +- **Security**: the master password never leaves the browser or extension; the flag is not sensitive. The control stops a person at an unlocked, unattended screen; it does not protect against a tampered client, which the dialog's help text says. +- **Cross-app**: none. diff --git a/openspec/changes/crypto-item-reprompt/specs/item-master-password-reprompt/spec.md b/openspec/changes/crypto-item-reprompt/specs/item-master-password-reprompt/spec.md new file mode 100644 index 000000000..28b6bf096 --- /dev/null +++ b/openspec/changes/crypto-item-reprompt/specs/item-master-password-reprompt/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: A per-item master password re-prompt + +The system MUST let the holder of a secret switch on "Ask for my master password before showing or filling this item" in the create and edit dialogs, stored as the `reprompt` flag on the holder's row. A new share copy MUST start with the owner's value. The vault list MUST mark flagged items. + +#### Scenario: A vault user flags a sensitive login + +- **GIVEN** a vault user editing the login "Domain admin" in the edit dialog +- **WHEN** the user switches on the re-prompt option and saves +- **THEN** the login shows a lock marker in the vault list at /secrets + +### Requirement: The web app verifies the master password before revealing a flagged item + +For a flagged secret, the web app MUST verify the master password in the browser, against the user's encrypted private-key envelope, before it reveals the value, copies the value or username, opens the edit dialog, clones, prints or shows a QR code. Each such action MUST ask again. A wrong password MUST reveal nothing. The master password MUST NOT be sent to the server. + +#### Scenario: A colleague at an unlocked screen + +- **GIVEN** a vault user who left their unlocked vault open with the flagged login "Domain admin" +- **WHEN** someone clicks the reveal button on that login in the secret detail sidebar and enters a wrong master password +- **THEN** the value stays hidden +- **AND** no request carrying the entered password is made + +#### Scenario: The owner reveals the value + +- **GIVEN** the same flagged login +- **WHEN** the user clicks copy on the password and enters the right master password +- **THEN** the password is copied +- **AND** clicking copy again asks for the master password again + +### Requirement: The extension verifies the master password before filling a flagged item + +The browser extension MUST ask for the master password in its popup and verify it against the vault key envelope before filling a flagged item. A wrong or missing password MUST fill nothing. + +#### Scenario: Filling a flagged login from the extension + +- **GIVEN** an unlocked extension on the sign-in page of the flagged login's site +- **WHEN** the user picks the login in the popup +- **THEN** the popup asks for the master password +- **AND** the login is filled only after the right password is entered diff --git a/openspec/changes/crypto-item-reprompt/tasks.md b/openspec/changes/crypto-item-reprompt/tasks.md new file mode 100644 index 000000000..23bbb8836 --- /dev/null +++ b/openspec/changes/crypto-item-reprompt/tasks.md @@ -0,0 +1,24 @@ +# Tasks: ask for the master password again before a sensitive item is shown or filled + +## 1. Backend + +- [ ] 1.1 Add the `reprompt` column and entity field, accept it on create and update, return it in `jsonSerialize()`, and copy it onto new share copies; bump ``. Verify: PHPUnit for create, update and a new share copy. + +## 2. Web app + +- [ ] 2.1 Add `RepromptDialog.vue` and the `useReprompt()` guard on top of `verifyMasterPassword()`. Verify: vitest for accept, reject and cancel. +- [ ] 2.2 Guard `resolveKey`, every copy button, edit, clone, print and QR for flagged items, and add the checkbox to the create and edit dialogs and a lock marker to `SecretListItem.vue`. Verify: a vitest that enumerates the reveal paths of `SecretDetailSidebar.vue` and `SecretListItem.vue`, and a Playwright flow reveal a flagged item with a wrong and then the right master password. + +## 3. Browser extension + +- [ ] 3.1 Ask for and verify the master password in the popup before `doFill()` of a flagged item. Verify: extension unit test that a flagged fill without the password fills nothing. + +## 4. Docs + +- [ ] 4.1 Document the flag and what it does and does not protect against. Verify: docs build. + +## Acceptance criteria + +- A flagged item cannot be shown, copied, edited, cloned, printed, shown as a QR code or filled without entering the master password again. +- Each of those actions asks again; there is no window. +- The master password is never sent to the server. diff --git a/openspec/changes/crypto-new-device-approval/.openspec.yaml b/openspec/changes/crypto-new-device-approval/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/crypto-new-device-approval/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/crypto-new-device-approval/design.md b/openspec/changes/crypto-new-device-approval/design.md new file mode 100644 index 000000000..8783fbc53 --- /dev/null +++ b/openspec/changes/crypto-new-device-approval/design.md @@ -0,0 +1,82 @@ +# Design: new device approval + +## Context + +Code at development `4c214a9d`: + +- Web unlock: `src/views/LockScreen.vue` on /lock. `src/store/modules/session.js:134` `unlockWithRawKey(rawUnlockKey)` already unlocks from a raw unlock key: it fetches the active suite, runs `decryptPrivateKeyWithRawKey`, imports the non-extractable `CryptoKey`, and imports the raw key as the AES metadata key used by the offline cache. The passkey unlock uses it (`src/store/modules/passkey.js:236` to `:238`). +- The unlocked session holds only non-extractable keys, so no page can export the raw unlock key without the master password or a PRF passkey. The passkey enrolment re-asks for the master password for the same reason (`src/store/modules/passkey.js:94`). +- `deriveUnlockKeyRaw(password, salt)` (`src/crypto/aes.js:104`) rebuilds the raw unlock key from the master password and the envelope salt. +- HPKE base mode in `src/crypto/hpke.js` (`generateRecipientKeyPair` `:200`, `seal` `:315`, `open` `:342`). +- Extension unlock: `browser-extension/src/lib/vault.js:49` takes a master password only; the change `clients-extension-unlock-lock-and-accounts` adds a raw-key unlock message to the worker. +- Vault-key proofs: `lib/Service/VaultKeyProofService.php:60` to `:74`, the `#[VaultKeyProofRequired]` attribute, and `tests/Unit/Controller/VaultKeyProofAttributesTest.php`. +- Notifications: `lib/Notification/KeepiqNotifier.php` subjects, routed through `NotificationService::SUBJECT_SETTING_MAP`. +- Rate limiting: Nextcloud's `#[UserRateLimit]` attribute (`OCP\AppFramework\Http\Attribute\UserRateLimit`); the app already uses `#[AnonRateLimit]` (`lib/Controller/ApplicationSecretRequestsController.php:88`). + +## Goals / Non-Goals + +**Goals:** + +- A user unlocks a new browser or the extension by approving it from a device where Keepiq is unlocked, without typing the master password on the new device. +- The server relays only ciphertext it cannot open. +- A person who holds only the user's Nextcloud session cannot get the vault opened without the user noticing and approving. +- An administrator-held path for users enrolled in organisation account recovery, with the server still keyless. + +**Non-Goals:** + +- The Go CLI as a requesting device. It keeps the master password; HPKE in Go is a follow-up. +- The extension as an approving device. +- Remembering the new device. The approval unlocks one session; the next unlock needs the master password, a passkey, or another approval. +- Signing in to Nextcloud itself. Nextcloud owns login; this change is about the vault. + +## Decisions + +### D1: The new device brings a one-time key + +The requesting client generates an X25519 key pair with `generateRecipientKeyPair()`, keeps the private key in memory for the life of the request, and calls `POST /api/v1/device-approvals` with the public key, its client kind (`web` or `extension`) and a device label (browser and OS from the user agent). The server stores the request with the caller's IP address and user agent, a 15 minute expiry, and the hash of a random request secret it returns once. Only the creating client knows that secret, and pickup requires it. + +### D2: One verification phrase on both screens + +Both devices show a phrase derived from the SHA-256 of the one-time public key: five words from a fixed word list, in the same helper the account recovery change uses. If the server, or anyone in between, swapped the key, the phrases differ and the user denies. + +### D3: Approval needs the master password or a passkey + +The unlocked web app sees pending requests through `GET /api/v1/device-approvals/pending` (polled while unlocked, and opened from the Nextcloud notification). The dialog shows the device label, the client kind, the IP address, the time and the phrase, with the warning "Only approve a device you are using right now." + +To approve, the user confirms their master password (or a PRF passkey). The browser derives the raw unlock key with `deriveUnlockKeyRaw` (or unwraps it with the passkey), checks it against the suite envelope, seals it with HPKE to the request public key (`info` `keepiq-device-approval-v1`, `aad` the request id), and calls `POST /api/v1/device-approvals/{id}/approve` with the sealed key. The route carries `#[VaultKeyProofRequired(binds: ['id', 'sealedUnlockKey'], subject: 'active', purpose: 'approve-device')]`, so an unlocked tab running injected script cannot approve on its own. + +Alternative considered: sealing the RSA private key instead of the raw unlock key. Rejected: the web session also needs the raw unlock key as its offline metadata key, and the unlock path from a raw key already exists and is tested. + +### D4: Pickup is one-time + +The requesting client polls `GET /api/v1/device-approvals/{id}` with its request secret every three seconds until approval, denial or expiry. On approval the response carries the sealed key once; the server then clears it and marks the request `consumed`. The client opens it with its one-time private key, unlocks through `unlockWithRawKey` (web) or the worker's raw-key unlock (extension), and drops the one-time key. + +### D5: Deny, expire, limit, notify + +`POST /api/v1/device-approvals/{id}/deny` ends a request; the dialog then offers a link to the Nextcloud security settings to end other sessions. A background job marks requests past their expiry as `expired`. `POST /api/v1/device-approvals` is limited with `#[UserRateLimit(limit: 3, period: 3600)]`. Each request raises a Nextcloud notification (`device_approval_requested`). Creation, approval, denial, expiry and pickup are audited with identifiers only. App config `device_approval_enabled` (default true) lets an administrator turn the feature off; when off, creation is refused and the option is hidden. + +### D6: The administrator-held path is organisation account recovery + +An administrator cannot approve a device on their own: the server has no key to give. For a user enrolled in organisation account recovery, the new device offers "Ask your organisation instead". That files a recovery request (change `crypto-organisation-account-recovery`) carrying the device's one-time key and the purpose `device`. Officers approve it as any recovery request, with the verification phrase and the threshold. The handoff seals the private key to the device's key. For purpose `device` the user's browser unlocks the session with the recovered private key and is not asked to set a new master password; the offline cache stays off for that session because no raw unlock key is present. For users who are not enrolled, the option is not shown. + +## Security and zero-knowledge + +Stored per request: the user id, the client kind, the device label, the IP address and user agent, the one-time public key, the request secret hash, the status, the times, and, between approval and pickup, the HPKE-sealed unlock key. The server cannot open the sealed key: only the requesting device holds the one-time private key. It never sees the master password, the raw unlock key or the private key (ADR-003). + +A stolen Nextcloud session can create a request, but opening the vault still needs the real user to approve it on an unlocked device with their master password or passkey, after seeing the device details and the phrase. The notification and the audit trail make every attempt visible, and the rate limit caps prompt spam. + +The raw unlock key exists briefly in the approving page and in the requesting client's memory, never in storage. + +## Risks / Trade-offs + +- **A user approves without reading.** The dialog leads with the device details and the phrase and needs a password or passkey; the administrator can turn the feature off. +- **The new device must stay open** until approval. A closed tab loses the one-time key; the request then expires unused. +- **Approvals only from the web app.** Users whose only unlocked client is the extension must use the master password on the new device. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. The Playwright flow uses two browser contexts for one seeded user. + +## Migration + +A new table `keepiq_device_approvals`: id, user_id, client_kind, device_label, requester_ip, requester_agent, request_public_key, request_secret_hash, status (`pending`, `approved`, `denied`, `expired`, `consumed`), created_at, expires_at, decided_at, sealed_unlock_key (TEXT, nullable), with an index on user and status. The `` in `appinfo/info.xml` must bump. diff --git a/openspec/changes/crypto-new-device-approval/proposal.md b/openspec/changes/crypto-new-device-approval/proposal.md new file mode 100644 index 000000000..b242a1599 --- /dev/null +++ b/openspec/changes/crypto-new-device-approval/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Approve a new device from a device that is already unlocked + +## Why + +Every device unlocks the Keepiq vault by typing the master password. A user setting up the browser extension, or opening Keepiq on a new laptop, has to type a long password on a keyboard they may not trust yet, and there is no way to let a device they already use vouch for the new one. Competitors let a signed-in device approve the new one, and some let an administrator do it. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| crypto-24 | Approve a sign-in on a new device from a device where you are already signed in, or have an administrator approve it. | Sign-in is Nextcloud's; a new device unlocks the vault with the user's passphrase, and there is no approve-from-another-device or admin approval flow. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. A search for device approval, auth requests or login requests in `lib/`, `src/` and `appinfo/routes.php` finds nothing; the web app unlocks at /lock with the master password or a passkey (`src/store/modules/passkey.js:193`), and the extension with the master password (`browser-extension/src/popup/popup.js:182`). + +### Demand + +- changelog: https://github.com/bitwarden/server/releases/tag/v2026.4.0 + +### Competitors rated yes + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Api/Auth/Controllers/AuthRequestsController.cs:78 POST auth-requests, :91 admin-request, :104 PUT {id} (approve); bitwarden/clients@web-v2026.9.0 libs/angular/src/auth/login-approval/login-approval-dialog.component.ts; bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.ts ..." +- Passbolt: "passbolt/passbolt_api@v5.16.0 plugins/PassboltEe/AccountRecovery/config/routes.php:52 POST /account-recovery/requests, :61 admin review, :68 responses; plugins/PassboltCe/Mobile/config/routes.php:27 transfer from a signed-in browser ... A new mobile or desktop device is set up from a browser where the user is signed in, and a lost browser can be restored through an account recovery request that an admin approves (Pro)." +- Nextcloud Passwords: "marius-wieschollek/passwords@2026.9.0 src/lib/Controller/Link/ConnectController.php:136 request() from the new client, :221 confirm() by the signed-in web session, :260 apply(codes); src/vue/Dialog/ConnectClient.vue with ConnectConfirm.vue; :242 new client notification Note: PassLink lets a new extension or app sign in by being approved from a browser where you are already signed in; no admin approval option." + +## What Changes + +- On the lock screen at /lock, and in the locked extension popup, a user can choose "Approve from another device". The new device makes a one-time key pair, sends the public key, and shows a verification phrase. +- The user's unlocked web app shows the request with the device details and the same phrase. After comparing the phrase and confirming their master password (or passkey), the user approves. Their browser seals the vault unlock key to the new device's one-time key; the server only relays the sealed result. +- The new device opens the sealed key and unlocks for this session. The server deletes the sealed result on first pickup. +- Requests expire after 15 minutes, are rate-limited, are announced by a Nextcloud notification, and are audited. The user can deny a request and is then pointed to end their other Nextcloud sessions. +- An administrator-held path exists only for users enrolled in organisation account recovery (change `crypto-organisation-account-recovery`): the device request becomes a recovery request for that device, approved by recovery officers, and the server stays keyless. +- An administrator can turn device approval off. + +## Capabilities + +### New Capabilities + +- `new-device-approval`: device approval requests, the verification phrase, approval with a sealed unlock key, pickup, denial, limits, and the officer path for enrolled users. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: a `DeviceApprovalController` and service, a new vault-key proof purpose `approve-device`, a background job that expires requests, and a notification subject. +- **Frontend**: the lock screen and an approval dialog in the web app; the locked view of the extension popup. +- **Database**: one new table; a migration and a `` bump. +- **Security**: the server relays only an HPKE-sealed unlock key it cannot open; approval needs the master password or a passkey on the approving device; the phrase defends against a swapped key. +- **Cross-app**: none. The officer path depends on `crypto-organisation-account-recovery` being built first. diff --git a/openspec/changes/crypto-new-device-approval/specs/new-device-approval/spec.md b/openspec/changes/crypto-new-device-approval/specs/new-device-approval/spec.md new file mode 100644 index 000000000..da4f1f92b --- /dev/null +++ b/openspec/changes/crypto-new-device-approval/specs/new-device-approval/spec.md @@ -0,0 +1,96 @@ +## ADDED Requirements + +### Requirement: A new device requests approval with a one-time key + +A signed-in client whose vault is locked (the web app at /lock, or the paired browser extension) MUST be able to request approval by generating a one-time X25519 key pair, keeping the private key in memory only, and calling `POST /api/v1/device-approvals` with the public key, its client kind and a device label. The system MUST store the request with the caller's IP address and user agent and a 15 minute expiry, MUST return a request secret once, MUST raise a Nextcloud notification to the user, and MUST refuse more than three requests per user per hour. + +#### Scenario: A user asks from a new laptop + +- **GIVEN** a user signed in to Nextcloud on a new laptop whose Keepiq vault is locked +- **WHEN** they choose "Approve from another device" on the lock screen at /lock +- **THEN** a pending request MUST be stored with a 15 minute expiry and the laptop MUST show a verification phrase +- **AND** the user MUST receive a Nextcloud notification about the request + +#### Scenario: Request spam is limited + +- **GIVEN** a user who created three requests in the last hour +- **WHEN** a fourth `POST /api/v1/device-approvals` arrives +- **THEN** the system MUST refuse it with a rate-limit response + +### Requirement: Both devices show the same verification phrase + +The requesting device and the approval dialog MUST show a phrase derived from the SHA-256 of the one-time public key. The approval dialog MUST also show the device label, client kind, IP address and request time. + +#### Scenario: A swapped key is visible + +- **GIVEN** a request whose public key was replaced on its way to the approving device +- **WHEN** the user compares the two screens +- **THEN** the phrases MUST differ + +### Requirement: Approval seals the unlock key and needs proof of the master password + +An unlocked user MUST be able to approve a pending request of their own from the web app only after confirming their master password or a PRF passkey. The approving browser MUST seal the raw vault unlock key to the request public key with HPKE, and MUST send only the sealed key. `POST /api/v1/device-approvals/{id}/approve` MUST require a vault-key proof from the user's active suite for purpose `approve-device` bound to the request id and the sealed key. The system MUST refuse approval of an expired, decided or foreign request. + +#### Scenario: The user approves their own new laptop + +- **GIVEN** a user with Keepiq unlocked on their desktop and a pending request from their new laptop with matching phrases +- **WHEN** they approve in the dialog and confirm their master password +- **THEN** the request MUST become `approved` and hold a sealed unlock key +- **AND** the approve request MUST NOT contain the master password or the raw unlock key + +#### Scenario: An unlocked tab cannot approve by itself + +- **GIVEN** an unlocked web app session and a pending request +- **WHEN** a script calls the approve route with a sealed key but without a vault-key proof +- **THEN** the system MUST refuse the approval and the request MUST stay pending + +### Requirement: Pickup is one-time and unlocks one session + +The requesting client MUST fetch the result with its request secret. The system MUST return the sealed key at most once and then clear it and mark the request `consumed`. The client MUST open the sealed key with its one-time private key, unlock its session, and discard the one-time key. The unlock MUST NOT be remembered beyond that session. + +#### Scenario: The new laptop unlocks + +- **GIVEN** an approved request +- **WHEN** the new laptop polls `GET /api/v1/device-approvals/{id}` with its request secret +- **THEN** it MUST receive the sealed key and unlock its vault view +- **AND** a second fetch MUST return no key + +#### Scenario: The extension unlocks through approval + +- **GIVEN** a paired but locked browser extension +- **WHEN** the user requests approval from the popup and approves it in their unlocked web app +- **THEN** the extension MUST unlock and list matching logins for the current site + +### Requirement: Deny, expiry, audit and administrator switch + +The user MUST be able to deny a pending request, after which the dialog MUST point to the Nextcloud security settings to end other sessions. The system MUST expire pending requests after 15 minutes, MUST audit creation, approval, denial, expiry and pickup with identifiers only, and MUST let an administrator turn device approval off with `device_approval_enabled`. + +#### Scenario: An unknown device is denied + +- **GIVEN** a pending request from a device the user does not recognise +- **WHEN** the user denies it +- **THEN** the request MUST become `denied` and no key MUST ever be released for it +- **AND** the dialog MUST offer the link to end other sessions + +#### Scenario: Feature switched off + +- **GIVEN** an administrator who turned device approval off +- **WHEN** a user opens the lock screen at /lock +- **THEN** the "Approve from another device" option MUST NOT be shown and the create route MUST refuse + +### Requirement: The administrator path goes through organisation account recovery + +For a user enrolled in organisation account recovery, the requesting device MUST offer to file a recovery request with purpose `device` carrying its one-time key, approved by recovery officers under that capability's threshold and verification phrase. For purpose `device`, the device MUST unlock the session with the recovered private key without asking for a new master password. For a user who is not enrolled, no administrator path MUST be offered. + +#### Scenario: Officers approve a device for an enrolled user + +- **GIVEN** an enrolled user on a new laptop with no other unlocked device +- **WHEN** they choose "Ask your organisation instead" and the required officers approve after comparing the phrase +- **THEN** the laptop MUST unlock the vault for this session +- **AND** the server MUST never have held a key that opens the handoff + +#### Scenario: Not enrolled, no administrator path + +- **GIVEN** a user who is not enrolled in organisation account recovery +- **WHEN** they open the device approval screen +- **THEN** the option to ask the organisation MUST NOT be shown diff --git a/openspec/changes/crypto-new-device-approval/tasks.md b/openspec/changes/crypto-new-device-approval/tasks.md new file mode 100644 index 000000000..043496a2d --- /dev/null +++ b/openspec/changes/crypto-new-device-approval/tasks.md @@ -0,0 +1,37 @@ +# Tasks: new device approval + +## 1. Server + +- [ ] 1.1 Add the `keepiq_device_approvals` table, entity and mapper with a migration and a `` bump. Verify: a PHPUnit migration test asserts the table and index. +- [ ] 1.2 Add `POST /api/v1/device-approvals` (own user only, `#[UserRateLimit(limit: 3, period: 3600)]`, refused when `device_approval_enabled` is false) returning the request id and a one-time request secret, and raising the `device_approval_requested` notification. Verify: PHPUnit for creation, the disabled setting and the notification; a controller attribute test for the rate limit. +- [ ] 1.3 Add `GET /api/v1/device-approvals/pending` and `POST /api/v1/device-approvals/{id}/deny`, both scoped to the request's own user. Verify: PHPUnit refuses another user's request with the same answer as an unknown id. +- [ ] 1.4 Add `POST /api/v1/device-approvals/{id}/approve` with `#[VaultKeyProofRequired(binds: ['id', 'sealedUnlockKey'], subject: 'active', purpose: 'approve-device')]`, and add it to `VaultKeyProofAttributesTest`. Verify: PHPUnit refuses an approval without a proof, for an expired request, and for another user. +- [ ] 1.5 Add `GET /api/v1/device-approvals/{id}` that needs the request secret, returns the sealed key once and marks the request `consumed`. Verify: PHPUnit asserts a second pickup returns no key and a wrong secret is refused. +- [ ] 1.6 Add a background job that expires pending requests and audit events for every transition (identifiers only). Verify: PHPUnit for the job and for audit metadata holding no key. + +## 2. Web app + +- [ ] 2.1 Add the verification phrase helper in `src/crypto/` (shared with account recovery) and the "Approve from another device" path on the lock screen at /lock: one-time key, request, phrase, polling, unlock through `unlockWithRawKey`. Verify: vitest for the phrase and for the unlock after a mocked approval. +- [ ] 2.2 Add the approval dialog in `src/dialogs/` for an unlocked user: device details, phrase, warning, master password or passkey confirmation, HPKE seal, approve and deny. Verify: vitest asserts the approve request holds only the sealed key and the proof headers. +- [ ] 2.3 Add the `device_approval_enabled` switch to the admin settings. Verify: vitest for the switch; PHPUnit for the default. + +## 3. Extension + +- [ ] 3.1 Add "Approve from another device" to the locked popup, sealing and unlocking through the worker's raw-key unlock. Verify: vitest with a mocked API unlocks the worker from an approved request. + +## 4. Officer path + +- [ ] 4.1 For users enrolled in organisation account recovery, add "Ask your organisation instead", filing a recovery request with purpose `device` and the device's one-time key, and unlocking the session from the recovered private key without a password reset. Verify: vitest for the purpose flag and the unlock; depends on `crypto-organisation-account-recovery`. + +## 5. End to end + +- [ ] 5.1 Add a Playwright flow with two browser contexts for one user: the second context requests approval, the first approves after the phrases match, and the second context unlocks and lists the vault. Verify: the Playwright spec passes in the E2E job. + +## Acceptance criteria + +- A user can unlock a new browser or the extension by approving it from their unlocked web app, without typing the master password on the new device. +- The server stores and relays only a sealed unlock key it cannot open, and deletes it at first pickup. +- An approval needs the master password or a passkey on the approving device, and a valid vault-key proof. +- Both devices show the same verification phrase; a swapped key shows different phrases. +- Requests expire after 15 minutes, are limited to three per hour, raise a notification and are audited. +- The administrator path exists only for users enrolled in organisation account recovery. diff --git a/openspec/changes/crypto-organisation-account-recovery/.openspec.yaml b/openspec/changes/crypto-organisation-account-recovery/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/crypto-organisation-account-recovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/crypto-organisation-account-recovery/design.md b/openspec/changes/crypto-organisation-account-recovery/design.md new file mode 100644 index 000000000..0e0234c73 --- /dev/null +++ b/openspec/changes/crypto-organisation-account-recovery/design.md @@ -0,0 +1,113 @@ +# Design: organisation account recovery + +## Context + +Code at development `4c214a9d`: + +- Key hierarchy (ADR-003, `src/crypto/aes.js`): master password plus the suite envelope's salt gives the raw unlock key (`deriveUnlockKeyRaw`, `:104`), which decrypts the AES-wrapped RSA-4096 suite private key (`decryptPrivateKey` `:79`, `decryptPrivateKeyWithRawKey` `:132`). The browser holds the private key as a non-extractable `CryptoKey`. +- Emergency access escrows the grantor's private key PEM with a hybrid envelope to a grantee certificate: `buildRecoveryEnvelope(privateKeyPem, granteeCertificatePem)` (`src/crypto/emergencyEnvelope.js:52`, AES-256-GCM content key RSA-OAEP wrapped) and `openRecoveryEnvelope()` (`:93`). The server stores only the envelope (`lib/Service/EmergencyAccessService.php`, `designate` `:161`, `fetchEnvelope` `:370` releasing it only to the named grantee in the `approved` state). On rotation the rotating browser re-envelopes contacts (`lib/Controller/MigrationController.php:514`, route `appinfo/routes.php:68`), and `lib/Listener/EmergencyAccessSuiteRotationListener.php:44` sweeps the rest. +- Replacing a suite's private-key wrapping: `PUT /api/v1/suites/{id}/private-key` (`lib/Controller/EncryptionSuiteController.php:254`), guarded by `#[VaultKeyProofRequired(binds: ['encryptedPrivateKey'], subject: 'routeParam:id', ...)]` at `:249`: a signature by that suite's own private key. +- Vault-key proofs (`docs/ARCHITECTURE.md` section 4.2, `lib/Service/VaultKeyProofService.php:60` to `:74` for the purposes) and the reflection test `tests/Unit/Controller/VaultKeyProofAttributesTest.php`. +- Administrator force-revocation (ADR-005): `EncryptionSuiteController::forceRevoke`, route `appinfo/routes.php:40`, UI `src/components/settings/AdminSuiteSection.vue:21` and `:180`. Revocation fires `EncryptionSuiteRevokedEvent` (`lib/Listener/EncryptionSuiteRevokedListener.php:45`). +- CA issuance: `lib/Service/CertificateIssuanceService.php:114` `signPublicKey()` and `:211` `signCsr()`. +- HPKE base mode (X25519, HKDF-SHA256, AES-256-GCM) in `src/crypto/hpke.js` (`generateRecipientKeyPair` `:200`, `seal` `:315`, `open` `:342`). +- Lock screen: `src/views/LockScreen.vue`, route `/lock` (`src/router/guards.js`). + +## Goals / Non-Goals + +**Goals:** + +- A user who forgot their master password regains their own vault, with the same key pair and every secret readable. +- The server never holds the recovery private key, a user's private key, or the master password in usable form. +- No single person can recover an account alone when the threshold is two or more. +- A user knows whether they are enrolled, and knows when a recovery happened. + +**Non-Goals:** + +- Recovery for application-owned suites. Applications hold their own keys; ADR-005 force-revocation stays their route. +- Splitting the recovery private key into threshold shares (Shamir). See D2. +- Administrators reading a user's secrets. Recovery gives the key to the user's own browser only. +- A recovery key held on paper or offline hardware outside Keepiq. + +## Decisions + +### D1: The escrow mirrors emergency access + +Enrolment is `buildRecoveryEnvelope(privateKeyPem, recoveryCertificatePem)`: the same hybrid envelope, the same client-side build, the same "server stores only the envelope" rule, with the organisation recovery certificate as recipient. The user's browser needs the private key PEM, so enrolment asks for the master password once (at the next unlock under the required policy, where the password is already in hand). + +Wrapping the private key rather than the raw unlock key means a routine master password change keeps the enrolment valid; a key rotation replaces it (D7). + +### D2: Officers hold the recovery key one copy each, and the server enforces a threshold + +An administrator names the officers and a threshold `k` (1 to the number of officers). An officer then generates the recovery key pair in their browser (`generateKeyPair`), gets the certificate issued through `signPublicKey()`, wraps the private key PEM to every officer's current suite certificate with the same hybrid envelope, posts the wrapped copies, and discards the key. The server stores the certificate and one wrapped copy per officer. + +A recovery needs `k` distinct officer approvals, each a vault-key proof. Only then does the server release the user's enrolment envelope, and only to an officer who approved. + +Alternative considered: Shamir splitting of the recovery private key so that `k` officers must each contribute a share. Rejected for this change: the shares must be combined in one browser, which then holds the full key anyway, and every officer change forces a new split and a full re-enrolment. The honest limit of D2 is stated in the security section. + +### D3: The request carries a one-time key and a verification phrase + +On the lock screen a user who is enrolled can choose "Forgot your master password?". Their browser generates an X25519 key pair (`src/crypto/hpke.js`), keeps the private key in IndexedDB as a non-extractable key bound to the request id, and posts the public key. The request expires after 72 hours. Both the user's screen and each officer's approval dialog show a verification phrase derived from the SHA-256 of that public key. The officer compares the phrase with the user over a channel they trust (in person or by phone) before approving. A phrase mismatch means the key was swapped on the way, and the officer declines. + +### D4: Handoff through one approving officer's browser + +When the threshold is met, the next approving officer who opens the request fetches the enrolment envelope and their own wrapped copy of the recovery key. Their browser opens the copy with their own suite key, opens the enrolment envelope with the recovery key, seals the user's private key PEM to the request public key with HPKE (`info` `keepiq-account-recovery-v1`, `aad` the request id), posts the sealed result, and discards everything. + +The user's browser (the one that made the request) fetches the sealed result, opens it with the request private key, asks for a new master password under the existing strength rules, wraps the private key with it, and calls `PUT /api/v1/suites/{id}/private-key` with a vault-key proof signed by the recovered key. The server marks the request fulfilled and deletes the sealed result. + +### D5: Policy and enrolment + +App config `account_recovery_policy`: `off` (default), `optional` or `required`. Under `optional` a user enrols or withdraws in their personal settings. Under `required` the web app enrols at the next unlock and tells the user, and withdrawal is refused. Before enrolling, the browser shows the recovery certificate's fingerprint, which administrators publish internally, and checks that the certificate chains to the instance CA. + +### D6: Approvals are proven, not just clicked + +`POST /api/v1/recovery/requests/{id}/approve` carries `#[VaultKeyProofRequired(binds: ['id'], subject: 'active', purpose: 'approve-account-recovery')]`, so an approval needs the officer's master password, not just their session. It is added to `VaultKeyProofAttributesTest`. An officer cannot approve a request for their own account. + +### D7: Enrolments and officer copies follow the suite + +A user's enrolment belongs to their suite. After a compromise-recovery rotation the rotating browser, which holds the new key, builds a fresh enrolment to the current recovery certificate, as it re-envelopes emergency contacts. A listener on `SuiteMigrationCompletedEvent` removes enrolments still on the old suite, and one on `EncryptionSuiteRevokedEvent` removes the revoked suite's enrolment and its open requests. + +An officer's wrapped copy is migrated the same way during their own rotation. Removing an officer deletes their copy; because they may have opened it before, the admin section offers to rotate the recovery key. Rotation creates a new key; each enrolled user's browser re-enrols at the next unlock; the old key is retired once no enrolment uses it, or at a deadline the administrator sets. + +### D8: Revocation remains the fallback + +`AdminSuiteSection.vue` warns when the suite being force-revoked belongs to an enrolled user: "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment." ADR-005's endpoint does not change. + +### Endpoints + +Admin (`#[AuthorizedAdminSetting(AdminSettings::class)]` and `#[PasswordConfirmationRequired]`): set officers, threshold and policy; retire a recovery key. Officer (`#[NoAdminRequired]`, in-body officer check): create the recovery key, list requests, approve (D6), decline, fetch the handoff material, post the sealed result, migrate their copy. User (`#[NoAdminRequired]`, own records only): read and write their enrolment, create a request, read their request and its sealed result, complete. All under `/api/v1/recovery/`, registered before the SPA catch-all. + +## Security and zero-knowledge + +Stored encrypted: each officer's copy of the recovery private key (hybrid envelope to that officer's suite certificate), each enrolment (the user's suite private key, hybrid envelope to the recovery certificate), and the short-lived sealed handoff (the user's private key, HPKE to the request key, deleted on completion). Stored in plain: the recovery certificate and fingerprint, officer ids, the threshold, the policy, request states, approvals, and the request public key. The server never holds a key that opens any of the ciphertext it stores (ADR-003). + +What this change honestly adds to the trust model: + +- **One officer's browser sees the recovered private key** for the moment of the handoff (D4). That is the price of recovery without a server-held key; Bitwarden and Passbolt have the same property. The user is told who handled it, and the web app offers a key rotation right after recovery. +- **Any single officer can open the recovery key.** The threshold is enforced by the server, which alone releases enrolment envelopes. A colluding server operator and one officer could bypass it. D2 records why Shamir splitting is not chosen now. +- **The recovery certificate and the request key come through the server**, like every recipient certificate in Keepiq sharing and emergency access. The fingerprint check at enrolment (D5) and the verification phrase at recovery (D3) let people detect a swap. + +Nothing here lets an administrator read a vault: an administrator who is not an officer holds no copy, and even officers get only ciphertext without an approved request. + +## Risks / Trade-offs + +- **The request browser must be the completion browser.** The request key lives in that browser's IndexedDB. A user who switches device files a new request. +- **Officers leaving the organisation.** D7's removal plus key rotation handles it; until rotation completes the removed officer may still hold an opened copy. +- **Required policy asks for the master password at unlock anyway**, so enrolment adds no prompt, only a notice. +- **Fewer officers than the threshold** (officers removed) would block every recovery; the admin section refuses a threshold above the officer count and warns when an officer has no active suite. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. The Playwright flow creates two officer users and one user through the existing E2E seed script `tests/e2e/ci-seed.sh`. + +## Migration + +New tables, through a new migration step: + +- `keepiq_recovery_keys`: id, certificate, fingerprint, threshold, status (`active`, `retiring`, `retired`), created_by, created_at, retired_at. +- `keepiq_recovery_officers`: id, recovery_key_id, officer_uid, officer_suite_id, wrapped_private_key (TEXT), added_by, added_at. +- `keepiq_recovery_enrolments`: id, user_id, suite_id, recovery_key_id, envelope (TEXT), enrolled_at. +- `keepiq_recovery_requests`: id, user_id, suite_id, enrolment_id, request_public_key, status (`pending`, `approved`, `fulfilled`, `declined`, `expired`), created_at, expires_at, handled_by, sealed_result (TEXT, nullable), fulfilled_at. +- `keepiq_recovery_approvals`: id, request_id, officer_uid, decision, decided_at, unique on request and officer. + +The `` in `appinfo/info.xml` must bump. diff --git a/openspec/changes/crypto-organisation-account-recovery/proposal.md b/openspec/changes/crypto-organisation-account-recovery/proposal.md new file mode 100644 index 000000000..8b5c59007 --- /dev/null +++ b/openspec/changes/crypto-organisation-account-recovery/proposal.md @@ -0,0 +1,55 @@ +--- +kind: code +--- + +# Organisation account recovery through named recovery officers + +## Why + +A Keepiq user who forgets their master password loses every secret they own. By design no administrator can restore access: the server never holds a usable key (ADR-003), and ADR-005 makes administrator force-revocation the lost-password route, which gives the user a fresh, empty vault. Organisations that keep business credentials in Keepiq need a way back that does not make the server a key holder. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| crypto-11 | Let an administrator restore a user's access after a forgotten master password. | By zero-knowledge design an administrator cannot restore access to a user's secrets. The admin can force-revoke the locked suite so the user can set up a fresh vault, but the old secrets stay unreadable unless the user has an emergency contact or a backup file. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. `src/components/settings/AdminSuiteSection.vue:21` and `:180` only force-revoke a suite. The one escrow in the code is emergency access, which wraps a grantor's private key to a chosen contact's certificate (`src/crypto/emergencyEnvelope.js:52`, `openspec/specs/emergency-access/spec.md`), never to an administrator. The decision records no non-goal: ADR-005 keeps the server keyless, and an opt-in recovery envelope to an organisation recovery certificate keeps it keyless too. + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/OrganizationUsersController.cs:558 PUT organizations/{orgId}/users/{id}/recover-account, :530 reset-password-enrollment; src/Core/AdminConsole/Enums/PolicyType.cs:17 ResetPassword policy ... Enterprise account recovery lets an admin set a new master password for an enrolled member." +- 1Password: "https://support.1password.com/recovery/ : administrators 'select Begin Recovery'; member gets new Secret Key and password" +- Passbolt: "passbolt/passbolt_api@v5.16.0 plugins/PassboltEe/AccountRecovery/config/routes.php:25 organization-policies, :52 requests, :88 POST /account-recovery/responses ... Pro account recovery escrows an encrypted copy of the user key; an admin with the organisation recovery key approves a request to restore access." +- HashiCorp Vault: "hashicorp/vault@v2.1.1 builtin/credential/userpass/path_user_password.go:39 users//password; ui/app/router.js access.method.item edit route for userpass users Note: An admin can set a new password for any userpass user; data is server-encrypted so nothing is lost." + +## What Changes + +- An administrator names recovery officers (Nextcloud users with an active suite) and a threshold of officer approvals, and sets the recovery policy: off, optional or required. +- An officer generates the organisation recovery key pair in their own browser. The certificate is public. The private key is wrapped to each officer's own suite certificate and then discarded; the server never holds it in usable form. +- A user enrols by letting their browser wrap their suite private key to the organisation recovery certificate, exactly as emergency access wraps it to a contact's certificate. Under the required policy the web app enrols at the next unlock and says so. +- A user who forgot their master password files a recovery request from the lock screen. Their browser makes a one-time key pair for the request and shows a verification phrase. +- Officers compare the phrase with the user over a trusted channel and approve with a vault-key proof. Once the threshold is met, one officer's browser opens the enrolment envelope and seals the user's private key to the request key. The user's browser opens it, asks for a new master password, and re-wraps the private key. +- Enrolments and officer copies follow the suite through rotation and revocation, officers can be added or removed, and the recovery key can be rotated. Every step is audited with identifiers only. + +## Capabilities + +### New Capabilities + +- `organisation-account-recovery`: officer-held organisation recovery key, user enrolment, recovery requests with a verification phrase, threshold approval, and client-side handoff of the recovered key. + +### Modified Capabilities + +None. ADR-005's force-revocation stays as it is; the admin suite section only gains a warning when the user is enrolled. + +## Impact + +- **Backend**: a `RecoveryController` and services for keys, officers, enrolments and requests; a new vault-key proof purpose `approve-account-recovery`; listeners on suite migration and revocation; notification subjects for requests and outcomes. +- **Frontend**: an admin section for officers, threshold and policy; an officer page for the key and the request queue; enrolment in the user settings; a "Forgot your master password?" path on the lock screen at /lock. +- **Database**: five new tables; a migration and a `` bump. +- **Security**: the server stores only public certificates and ciphertext; the recovery private key exists in usable form only in an officer's browser; a recovered private key passes through one officer's browser, which the user is told about and offered a key rotation for. +- **Cross-app**: none. diff --git a/openspec/changes/crypto-organisation-account-recovery/specs/organisation-account-recovery/spec.md b/openspec/changes/crypto-organisation-account-recovery/specs/organisation-account-recovery/spec.md new file mode 100644 index 000000000..f11a47c3b --- /dev/null +++ b/openspec/changes/crypto-organisation-account-recovery/specs/organisation-account-recovery/spec.md @@ -0,0 +1,125 @@ +## ADDED Requirements + +### Requirement: Administrators name recovery officers, a threshold and a policy + +The system MUST let an administrator, after Nextcloud password confirmation, name recovery officers from Nextcloud users with an active encryption suite, set an approval threshold between 1 and the number of officers, and set `account_recovery_policy` to `off` (default), `optional` or `required`. It MUST refuse a threshold above the officer count. + +#### Scenario: Administrator sets up two-person recovery + +- **GIVEN** an administrator on the account recovery section of the Keepiq admin settings who has confirmed their password +- **WHEN** they name officers `olga` and `omar`, set the threshold to 2 and the policy to `optional` +- **THEN** the settings MUST be stored and both officers MUST be notified that they are recovery officers + +#### Scenario: A threshold above the officer count is refused + +- **GIVEN** two named officers +- **WHEN** an administrator sets the threshold to 3 +- **THEN** the system MUST reject the change with a bad-request response + +### Requirement: The recovery private key is generated and held by officers only + +An officer MUST generate the organisation recovery key pair in their own browser. The system MUST store the recovery certificate and, per officer, the recovery private key wrapped to that officer's suite certificate, and MUST NOT receive the recovery private key in any other form. The system MUST return an officer's wrapped copy only to that officer. + +#### Scenario: Officer creates the recovery key + +- **GIVEN** officer `olga` with an unlocked vault on the recovery officer page +- **WHEN** she creates the organisation recovery key +- **THEN** the server MUST store a certificate and one wrapped copy for each named officer +- **AND** no request from her browser MUST contain the recovery private key unwrapped + +### Requirement: Users enrol by wrapping their own key to the recovery certificate + +Under the `optional` policy a user MUST be able to enrol and withdraw in their personal settings; under `required` the web app MUST enrol the user at their next unlock and MUST refuse withdrawal. Enrolment MUST happen in the user's browser: it MUST show the recovery certificate fingerprint, check that the certificate chains to the instance CA, wrap the user's suite private key to the recovery certificate with the emergency-access hybrid envelope, and send only that envelope. + +#### Scenario: Required policy enrols at unlock + +- **GIVEN** the policy is `required` and a user who is not enrolled +- **WHEN** the user unlocks their vault at /lock with their master password +- **THEN** the web app MUST post an enrolment envelope and tell the user they are enrolled, showing the certificate fingerprint +- **AND** the request MUST NOT contain the master password or the private key in plain + +#### Scenario: Withdrawal under a required policy is refused + +- **GIVEN** the policy is `required` and an enrolled user +- **WHEN** the user calls `DELETE /api/v1/recovery/enrolment` +- **THEN** the system MUST refuse and keep the enrolment + +### Requirement: A recovery request carries a one-time key and a verification phrase + +An enrolled user whose vault is locked MUST be able to file a recovery request from the lock screen. The user's browser MUST generate a one-time X25519 key pair, keep the private key in that browser only, and send the public key. The request MUST expire after 72 hours. The user's screen and every officer's approval dialog MUST show the same verification phrase derived from the request public key. + +#### Scenario: Forgotten password starts a request + +- **GIVEN** an enrolled user who forgot their master password +- **WHEN** they choose "Forgot your master password?" on the lock screen at /lock and confirm +- **THEN** a request MUST be created with state `pending` and a 72 hour expiry +- **AND** the lock screen MUST show a verification phrase, and every officer MUST be notified + +### Requirement: Recovery needs a threshold of proven officer approvals + +The system MUST count an approval only when it carries a vault-key proof from the approving officer's active suite for purpose `approve-account-recovery`, MUST count each officer once, MUST refuse an officer's approval of their own request, and MUST move a request to `approved` only when distinct approvals reach the threshold. Any officer MAY decline, which ends the request. + +#### Scenario: Two officers approve + +- **GIVEN** a pending request, a threshold of 2, and officers `olga` and `omar` who each compared the verification phrase with the user by phone +- **WHEN** both approve with a valid vault-key proof +- **THEN** the request MUST move to `approved` + +#### Scenario: An approval without a proof is refused + +- **GIVEN** a pending request +- **WHEN** an officer calls `POST /api/v1/recovery/requests/{id}/approve` with a valid session but no vault-key proof +- **THEN** the system MUST refuse the approval and the count MUST stay unchanged + +#### Scenario: Self-approval is refused + +- **GIVEN** officer `olga` who filed a recovery request for her own account +- **WHEN** she tries to approve it +- **THEN** the system MUST refuse the approval + +### Requirement: The recovered key reaches only the requesting browser + +For an `approved` request the system MUST release the user's enrolment envelope and the officer's own wrapped recovery key only to an officer who approved it. That officer's browser MUST seal the user's private key to the request public key with HPKE and post only the sealed result. The system MUST release the sealed result only to the requesting user. The user's browser MUST open it with the request private key, set a new master password, and replace the suite's private-key wrapping through `PUT /api/v1/suites/{id}/private-key` with a vault-key proof by the recovered key. The system MUST delete the sealed result when the request completes. + +#### Scenario: User completes the recovery + +- **GIVEN** an approved request whose sealed result an approving officer has posted +- **WHEN** the user, in the browser that filed the request, opens the recovery screen and sets a new master password +- **THEN** their suite MUST be re-wrapped under the new password and their existing secrets MUST decrypt +- **AND** the request MUST be `fulfilled` and the sealed result MUST no longer be stored + +#### Scenario: Nobody else can fetch the handoff material + +- **GIVEN** an approved request +- **WHEN** a user who is not an approving officer asks for the handoff material, or anyone but the requester asks for the sealed result +- **THEN** the system MUST refuse with the same response it gives for an unknown request + +### Requirement: The user is told what happened and offered a rotation + +After completion the web app MUST tell the user which officer handled the recovery and MUST offer a compromise-recovery key rotation. Every recovery step MUST be recorded in the audit trail with identifiers only. + +#### Scenario: Recovery notice + +- **GIVEN** a user who just completed a recovery handled by officer `omar` +- **WHEN** the vault opens +- **THEN** the web app MUST show that `omar` handled the recovery and offer to rotate the vault key + +### Requirement: Enrolments and officer copies follow the suite + +A compromise-recovery rotation MUST rebuild the user's enrolment for the new suite in the rotating browser, and the system MUST delete enrolments left on the old suite once the migration completes. Revoking a suite MUST delete its enrolment and end its open requests. An officer's rotation MUST re-wrap their recovery copy to their new suite. Removing an officer MUST delete their copy and the admin section MUST offer a recovery key rotation, after which each enrolled user's browser re-enrols at its next unlock. + +#### Scenario: Rotation keeps the user enrolled + +- **GIVEN** an enrolled user who completes a compromise-recovery rotation +- **WHEN** the migration completes +- **THEN** exactly one enrolment MUST exist for the user, bound to the new suite + +### Requirement: Force-revocation warns about enrolled users + +The administrator suite section MUST warn, before a force-revocation, that the suite's owner is enrolled in account recovery and that recovery keeps their secrets while revocation deletes the enrolment. + +#### Scenario: Warning before revoking an enrolled user's suite + +- **GIVEN** an administrator in the encryption suites section of the Keepiq admin settings +- **WHEN** they enter the suite id of an enrolled user +- **THEN** the section MUST show the enrolled-user warning before the force-revoke action diff --git a/openspec/changes/crypto-organisation-account-recovery/tasks.md b/openspec/changes/crypto-organisation-account-recovery/tasks.md new file mode 100644 index 000000000..83d042e8a --- /dev/null +++ b/openspec/changes/crypto-organisation-account-recovery/tasks.md @@ -0,0 +1,44 @@ +# Tasks: organisation account recovery + +## 1. Data and configuration + +- [ ] 1.1 Add the five recovery tables with entities and mappers, a migration step and a `` bump. Verify: a PHPUnit migration test asserts each table and the unique approval index. +- [ ] 1.2 Add `account_recovery_policy` (default `off`), officer list and threshold handling to the admin settings service, refusing a threshold above the officer count and officers without an active suite. Verify: PHPUnit for each accept and reject path. + +## 2. Recovery key + +- [ ] 2.1 Add the officer endpoint that stores a recovery certificate issued through `CertificateIssuanceService::signPublicKey()` and one wrapped copy per officer, refusing a copy for a non-officer. Verify: PHPUnit asserts the stored copies and that no endpoint returns another officer's copy. +- [ ] 2.2 Add the officer page action that generates the key pair in the browser, wraps it for every officer, posts, and discards the key. Verify: vitest asserts the request body holds only wrapped copies and a public key. +- [ ] 2.3 Add officer add and remove, and recovery key rotation and retirement (D7). Verify: PHPUnit for removal deleting the copy and for a retired key refusing new enrolments. + +## 3. Enrolment + +- [ ] 3.1 Add the user enrolment endpoints (read, write, withdraw; withdraw refused under `required`). Verify: PHPUnit for each policy value. +- [ ] 3.2 Add enrolment in the user settings and at unlock under `required`, showing the certificate fingerprint and building the envelope with `buildRecoveryEnvelope`. Verify: vitest asserts the posted envelope opens with the recovery private key in a test and that no request carries the private key PEM. + +## 4. Requests and approvals + +- [ ] 4.1 Add request creation from the lock screen at /lock, the X25519 request key in IndexedDB, the 72 hour expiry, and the verification phrase. Verify: vitest for the phrase derivation and the stored key; PHPUnit for expiry. +- [ ] 4.2 Add approve (with the `approve-account-recovery` vault-key proof, listed in `VaultKeyProofAttributesTest`) and decline, refusing self-approval and counting distinct officers. Verify: PHPUnit for the proof requirement, self-approval, duplicate approvals and the threshold. +- [ ] 4.3 Release handoff material only to an approving officer after the threshold is met, accept the sealed result, and release it only to the requesting user. Verify: PHPUnit for every wrong-state and wrong-caller refusal, answered identically. +- [ ] 4.4 Add the officer approval dialog with the phrase and the handoff in the browser (D4). Verify: vitest asserts the sealed result opens with the request key and that the officer page keeps no key after posting. +- [ ] 4.5 Add completion: open the sealed result, set a new master password, call `PUT /api/v1/suites/{id}/private-key` with a proof by the recovered key, and offer a key rotation. Verify: vitest for the completion calls; PHPUnit asserts the sealed result is deleted on completion. + +## 5. Lifecycle, audit and notifications + +- [ ] 5.1 Add listeners that remove old-suite enrolments after a migration and a revoked suite's enrolment and open requests, and the re-enrolment step in the rotation flow. Verify: PHPUnit for both listeners; vitest for the rotation step. +- [ ] 5.2 Add audit events for key, officer, enrolment, request, approval and completion (identifiers only) and notification subjects for new requests and outcomes. Verify: PHPUnit asserts no audit metadata holds an envelope or key. +- [ ] 5.3 Add the enrolled-user warning to `AdminSuiteSection.vue`. Verify: vitest renders the warning for an enrolled user's suite. + +## 6. End to end + +- [ ] 6.1 Add a Playwright flow: a user enrols, forgets their password, files a request, two officers approve after comparing the phrase, and the user sets a new master password and reads their old secrets. Verify: the Playwright spec passes in the E2E job. + +## Acceptance criteria + +- An enrolled user who forgot their master password regains their vault with the same key pair and every secret readable. +- The server stores only certificates, public keys and ciphertext; no stored value opens without a key the server does not have. +- A recovery needs the configured number of distinct officer approvals, each proven with the officer's own vault key, and an officer cannot approve their own recovery. +- The recovered private key reaches only the browser that filed the request, sealed to that request's key. +- The user sees the verification phrase, is told who handled the recovery, and is offered a key rotation. +- Enrolments and officer copies survive routine password changes and are rebuilt or removed on rotation and revocation. diff --git a/openspec/changes/crypto-vault-encryption-details/design.md b/openspec/changes/crypto-vault-encryption-details/design.md new file mode 100644 index 000000000..990c0a423 --- /dev/null +++ b/openspec/changes/crypto-vault-encryption-details/design.md @@ -0,0 +1,50 @@ +# Design: show which algorithms and key sizes protect the vault + +## Context + +At development `4c214a9d`: + +- `src/crypto/rsa.js:2-8` RSA-OAEP-SHA256, `RSA_KEY_BITS = 4096`, chunking above 446 bytes; `:20-21` key generation with `modulusLength`; `:64`, `:187` import with SHA-256. +- `src/crypto/aes.js:2` AES-256-GCM with PBKDF2-SHA256; `:15` `PBKDF2_ITERATIONS = 600000`; `:55` `encryptPrivateKey()`, `:79` `decryptPrivateKey()`. +- `src/crypto/argon2.js:19-34` `ARGON2_MEMORY_KIB = 65536`, `ARGON2_ITERATIONS = 3`, `ARGON2_PARALLELISM = 1`, `SALT_LENGTH = 16`; used by the encrypted backup (`src/export/backup.js`), link shares and ephemeral sends. +- Attachments: AES-GCM file keys wrapped with RSA (`src/store/modules/attachment.js:115`). +- `src/App.vue:166-186` Encryption section: status, created, suite id. +- `lib/Service/CertificateLifecycleService.php:180-187` parsed certificate metadata: subject, issuer, serial, SHA-256 fingerprint, notBefore, notAfter; `lib/Controller/CertificateController.php:95` `inventory()`. +- `src/views/CertificateInventoryView.vue:116-137` vault certificate table: owner, subject, expires. +- `docs/ARCHITECTURE.md:65-117` describes the model in prose. + +## Goals / Non-Goals + +**Goals** +- Anyone with a vault can see what protects it, in words they understand and names an auditor recognises. +- The screen cannot say something the code does not do. + +**Non-Goals** +- Changing any algorithm or parameter. +- A per-secret view. Every secret of a suite uses the same scheme. + +## Decisions + +**D1. Read the parameters from the code, not from copy.** Each crypto module exports its parameters (`RSA_PARAMETERS`, `MASTER_KEY_PARAMETERS`, `ARGON2_PARAMETERS`) and `src/crypto/parameters.js` assembles `CRYPTO_PARAMETERS`. The component renders from that object. A vitest asserts the exported values are the ones the functions use, so changing a constant changes the screen. + +**D2. Read the certificate facts from the certificate.** The server already parses the vault certificate for the inventory. It adds `keyType` and `keyBits` from `openssl_pkey_get_details()` on the certificate's public key and `signatureAlgorithm` from the parsed certificate. The browser does not re-derive them. + +**D3. Plain words first, technical names second.** Each line reads like "Your passwords are encrypted with your own 4096-bit RSA key (RSA-OAEP, SHA-256)", following the hydra writing rules, with the technical name for auditors in the same line. + +**D4. The docs page is generated.** The docs build imports `src/crypto/parameters.js` and renders the same table, so the public page and the app agree. + +## Security and zero-knowledge + +Everything shown is public: algorithm names, parameter values, and the certificate's public fields. No private key, envelope, salt or secret is read or displayed. Publishing parameters does not weaken them. + +## Risks / Trade-offs + +- A reader may compare numbers across vendors without context (4096-bit RSA against 2048-bit, 600,000 against 1,000,000 iterations). The docs page explains what each number protects. + +## Seed data + +Keepiq owns its tables (keepiq ADR-001) and has no OpenRegister register. No fixture is needed; every dev vault has a suite. + +## Migration + +None. diff --git a/openspec/changes/crypto-vault-encryption-details/proposal.md b/openspec/changes/crypto-vault-encryption-details/proposal.md new file mode 100644 index 000000000..9fbac853c --- /dev/null +++ b/openspec/changes/crypto-vault-encryption-details/proposal.md @@ -0,0 +1,51 @@ +--- +kind: code +--- + +# Show which algorithms and key sizes protect the vault + +## Why + +Keepiq's encryption is stated in code constants and in the architecture document, not on any screen. The secret values are RSA-OAEP with SHA-256 under 4096-bit keys (`src/crypto/rsa.js:8`, `:20-21`, `:64`), the private key is wrapped with AES-256-GCM under a key derived from the master password with PBKDF2-SHA256 at 600,000 iterations (`src/crypto/aes.js:2`, `:15`), and backups and link shares use Argon2id with 64 MiB, 3 iterations and parallelism 1 (`src/crypto/argon2.js:19-25`). The user settings Encryption section shows only the suite's status, creation date and id (`src/App.vue:166-186`), and the certificates page shows the vault certificate's owner, subject and expiry (`src/views/CertificateInventoryView.vue:116-137`). A security officer who has to fill in a supplier questionnaire, or a user who wants to know what "zero-knowledge" means here, has to read source code. + +### Matrix rows (keepiq `openspec/parity/capabilities.json`) + +| row | capability | Keepiq today | +|---|---|---| +| `crypto-13` | See which algorithms and key sizes protect your vault. | `no`: no screen names the algorithms or key sizes; the certificates page shows subject and expiry only | + +### Demand + +No demand row. Three competitors rate it yes. + +### Competitors rated yes + +- 1Password: AES256-GCM, RSA-OAEP 2048, PBKDF2-HMAC-SHA256 (https://1passwordstatic.com/files/security/1password-white-paper.pdf). +- Passbolt: "src/react-extension/components/UserSetting/DisplayUserGpgInformation/DisplayUserGpgInformation.js:111 algorithm type, :272 algorithm cell (plus length, fingerprint, created, expires); passbolt/passbolt_api@v5.16.0 config/routes.php:119 GET /gpgkeys Note: The keys inspector shows key algorithm, length, fingerprint and expiry." +- Keeper: AES-256 record keys, PBKDF2 1,000,000 iterations, ECC secp256r1, RSA-2048 documented (https://docs.keeper.io/enterprise-guide/keeper-encryption-model). + +## What Changes + +- **An encryption overview in user settings.** The Encryption section lists, in plain words with the technical names next to them: how secret values are encrypted, how the private key is protected by the master password, how attachments are encrypted, how backups, link shares and sends are protected, and the vault certificate's key size, signature algorithm, fingerprint, issuer and validity. +- **One source of truth.** The web app exports the parameters from the crypto modules and the overview reads them from there, so the screen cannot drift from the code. The certificate facts are read from the certificate itself by the server. +- **The certificates page shows key size and algorithm** for the vault certificate next to its subject and expiry. +- **A documentation page** repeats the overview for readers without an account, generated from the same constants in the docs build. + +## Capabilities + +### New Capabilities + +- `vault-encryption-details`: a user-facing account of the algorithms, key sizes and key derivation settings that protect the vault, taken from the code and the certificate. + +### Modified Capabilities + +- None in delta form. `certificate-lifecycle` keeps its inventory requirement; the added certificate fields are this change's own requirement. + +## Impact + +- **Backend**: `CertificateLifecycleService` adds `keyType`, `keyBits` and `signatureAlgorithm` to the parsed certificate metadata of the inventory. +- **Frontend**: a `CRYPTO_PARAMETERS` export assembled from `src/crypto/rsa.js`, `aes.js` and `argon2.js`; a `VaultEncryptionDetails.vue` in the Encryption section of `App.vue`; two columns on `CertificateInventoryView.vue`. +- **Docs**: a generated section in `docs/ARCHITECTURE.md` or a new page under `docs/`. +- **Database**: none. +- **Security**: only public parameters and public certificate fields are shown; no key material. +- **Cross-app**: none. diff --git a/openspec/changes/crypto-vault-encryption-details/specs/vault-encryption-details/spec.md b/openspec/changes/crypto-vault-encryption-details/specs/vault-encryption-details/spec.md new file mode 100644 index 000000000..7cf5f0c9d --- /dev/null +++ b/openspec/changes/crypto-vault-encryption-details/specs/vault-encryption-details/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: The user sees what protects the vault + +The Encryption section of the user settings MUST state, in plain words with the technical names alongside: the algorithm, hash and key size that encrypt secret values; the algorithm and key derivation, with its iteration count, that protect the private key with the master password; how attachments are encrypted; and the key derivation and cipher that protect encrypted backups, link shares and sends. Every value shown MUST come from the parameters the crypto code exports, not from separate text. + +#### Scenario: A security officer fills in a supplier questionnaire + +- **GIVEN** a vault user with an active encryption suite +- **WHEN** the user opens the user settings and the Encryption section +- **THEN** the section says secret values are encrypted with RSA-OAEP, SHA-256, 4096-bit keys +- **AND** it says the private key is protected with AES-256-GCM under a key derived with PBKDF2-SHA256 at 600,000 iterations +- **AND** it names Argon2id with its memory, iterations and parallelism for backups and link shares + +#### Scenario: The screen follows the code + +- **GIVEN** a developer who raises the PBKDF2 iteration count in the crypto code +- **WHEN** the web app is rebuilt +- **THEN** the Encryption section shows the new count without any other edit + +### Requirement: The vault certificate shows its key and signature algorithm + +The certificate inventory MUST include the key type, key size and signature algorithm of each vault certificate, read by the server from the certificate. The certificates page MUST show them next to the subject and expiry. No private key material MUST be read or shown. + +#### Scenario: A vault user inspects their certificate + +- **GIVEN** a vault user on the certificates page at /certificates +- **WHEN** the vault encryption certificates table renders +- **THEN** the user's certificate row shows key type RSA, key size 4096 and its signature algorithm diff --git a/openspec/changes/crypto-vault-encryption-details/tasks.md b/openspec/changes/crypto-vault-encryption-details/tasks.md new file mode 100644 index 000000000..1ecb8677d --- /dev/null +++ b/openspec/changes/crypto-vault-encryption-details/tasks.md @@ -0,0 +1,21 @@ +# Tasks: show which algorithms and key sizes protect the vault + +## 1. Parameters + +- [ ] 1.1 Export the parameter objects from `rsa.js`, `aes.js` and `argon2.js` and assemble `src/crypto/parameters.js`. Verify: vitest that each exported value equals the value the module's functions use. +- [ ] 1.2 Add `keyType`, `keyBits` and `signatureAlgorithm` to the parsed certificate metadata in `CertificateLifecycleService`. Verify: PHPUnit on a generated 4096-bit test certificate. + +## 2. Screens + +- [ ] 2.1 Add `VaultEncryptionDetails.vue` to the Encryption section of the user settings, rendered from `CRYPTO_PARAMETERS` and the user's suite certificate. Verify: vitest on the rendered lines, and a Playwright check that the section names RSA-OAEP, 4096, AES-256-GCM, PBKDF2 and 600,000. +- [ ] 2.2 Add key size and signature algorithm columns to the vault certificate table in `CertificateInventoryView.vue`. Verify: Playwright check on /certificates. + +## 3. Docs + +- [ ] 3.1 Generate the encryption overview page in the docs build from `src/crypto/parameters.js`. Verify: docs build and a diff check that the page lists the same values as the app. + +## Acceptance criteria + +- The Encryption section of the user settings states how values, the private key, attachments and backups are protected, with algorithm names and parameters. +- The vault certificate's key type, key size and signature algorithm are shown on /certificates. +- Changing a parameter in the crypto code changes the screen and the docs page without other edits. 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..2ae80dac3 --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/design.md @@ -0,0 +1,164 @@ +# 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: Challenges are issued and checked statelessly; a verified proof is consumed once + +`nonce = base64(random) . '.' . HMAC(instance secret, random | uid | purpose | exp)`. Issuing and checking a challenge needs no storage: the middleware verifies the HMAC and the expiry. `purpose` binds the challenge to one route, so a proof for one guarded operation cannot be presented to another, and the signature commits to the operation's parameters. + +That binding was first thought to be enough on its own, since a replay would only re-authorise the byte-identical operation. It is not enough. On an upsert route the same parameters can do something different later: a designate proof replayed after the owner revoked that contact would recreate it (#804 review). So a proof MUST also be single-use. + +Once a proof has fully verified, its nonce is consumed in the distributed cache (`keepiq_proof_nonce`, keyed by `sha256(nonce)`, for the rest of the challenge's lifetime), the same way `JwtAuthService` handles `jti`. The write is an atomic `add()` on an `IMemcache`, and `hasKey()` then `set()` otherwise. Only a verified proof is consumed, so a bad signature cannot burn the nonce for the real one. + +This is best-effort by the cache's reach. Without a configured memcache Nextcloud hands out a NullCache whose `add()` always succeeds. A reuse is then not detected, but the guarded flows keep working, which was the reason D5 first avoided `ICacheFactory`. The service logs a warning, once per request, when no memcache is available. With a server-local cache a reuse is detected per server. A DB table keyed by `sha256(nonce)` would be atomic on every install; it was weighed and not chosen, to keep a table and its cleanup job out of this change. + +### 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..2749c2fc1 --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/specs/emergency-access/spec.md @@ -0,0 +1,51 @@ +## 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 + +### Requirement: Designate Emergency Contact +The system MUST allow a vault owner (grantor), while their vault is unlocked, to designate one or more Nextcloud users as emergency contacts. Each designation MUST record the grantee, an access level, and a wait period. The **v1 access level MUST be `view`** (the contact may read the grantor's vault); account takeover is out of scope for v1. The wait period MUST be grantor-configurable (at minimum the options 1, 3, 7, and 30 days; default 7). + +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). + +Designation MUST require a verified key proof (see the `vault-key-proof` capability), bound to the grantee, the wait period and the recovery envelope. A designation names who a later rotation escrows the grantor's private key to, and re-designating an existing contact replaces its envelope. With a session alone, an attacker could plant their own account as a grantee and receive the grantor's next key (keepiq#800), or overwrite an envelope and destroy break-glass, which the proof on revocation exists to prevent. + +#### Scenario: Designate a contact with a wait period +@e2e exclude State-machine/authorization contract — covered by PHPUnit EmergencyAccessServiceTest. +- **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, carrying a verified key proof +- **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. +- **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 +- **AND** no emergency-contact relationship MUST be created + +#### Scenario: Designation 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 A, and no key proof +- **WHEN** a designation of any grantee, or a re-designation of an existing contact, is requested +- **THEN** the system MUST refuse with `403` and `error: key_proof_required` +- **AND** no contact MUST be created, and an existing contact's envelope MUST be unchanged 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/gdpr-compliance/spec.md b/openspec/changes/harden-vault-key-material-guards/specs/gdpr-compliance/spec.md new file mode 100644 index 000000000..9c8be141b --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/specs/gdpr-compliance/spec.md @@ -0,0 +1,34 @@ +## MODIFIED Requirements + +### Requirement: Account Data Deletion +The system MUST support deletion of all of a user's Keepiq data (GDPR Art. 17, right to erasure) via two triggers running the same idempotent cascade: + +- **In-app**: gated by a verified key proof (see the `vault-key-proof` capability) AND a typed confirmation phrase; deletes Keepiq data while the Nextcloud account remains +- **Automatic**: a `UserDeletedEvent` listener runs the cascade when the Nextcloud account is deleted, so Keepiq data never outlives its account + +The in-app trigger wipes every secret, suite and migration in one request, so a Nextcloud session alone MUST NOT be sufficient for it. The master-password re-entry is therefore proven to the server as a signature made with the private key it unlocks, bound to the confirmation phrase, rather than checked only in the browser. The phrase stays as a guard against a slip. A user without an active EncryptionSuite cannot make a proof; their Keepiq data is removed through the automatic trigger when their Nextcloud account is deleted. + +The cascade MUST remove: the user's secrets and folders, their EncryptionSuites (including encrypted private keys) and SuiteMigration records, link shares, secret requests, share records per the shared-secret semantics requirement, and user settings. Every cascade step MUST be idempotent so an interrupted run can be safely re-executed. + +#### Scenario: In-app deletion double-gated +@e2e tests/e2e/workflows/export-gdpr.spec.ts +- **WHEN** a user initiates in-app account data deletion +- **THEN** the system MUST require master-password re-entry and the typed confirmation phrase +- **AND** failing either gate MUST abort with nothing deleted + +#### Scenario: In-app deletion 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 a user with an active EncryptionSuite +- **WHEN** in-app deletion is requested with the correct confirmation phrase but without a verified key proof +- **THEN** the system MUST refuse with `403` and `error: key_proof_required` +- **AND** nothing MUST be deleted + +#### Scenario: Nextcloud account deletion cascades +@e2e exclude Server-side lifecycle contract — the UserDeletedEvent listener runs the cascade with no UI; covered by PHPUnit (UserDeletedListenerTest triggers the cascade with the user-deleted trigger). +- **WHEN** a Nextcloud administrator deletes a user account +- **THEN** all of that user's Keepiq data MUST be removed by the listener-triggered cascade without any manual step + +#### Scenario: Interrupted cascade is re-runnable +@e2e exclude Server-side idempotency contract — re-running the cascade completes without error; covered by PHPUnit (AccountDeletionServiceTest idempotent re-run test). +- **WHEN** a deletion cascade is interrupted and triggered again +- **THEN** the re-run MUST complete the remaining steps without error 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..55fa5a627 --- /dev/null +++ b/openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md @@ -0,0 +1,130 @@ +## 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, or that escrows the owner's private key to another party, 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 issue or check a challenge: Nextcloud returns a null cache when none is configured, and the guarded flows MUST keep working on such an installation. + +A proof MUST be single-use. Binding a proof to its operation's parameters is not enough on its own: on an upsert route the same parameters can do something different later, and a designate proof replayed after the owner revoked that contact would recreate it. The system MUST therefore consume a proof's nonce when the proof verifies, and MUST refuse the same nonce again for the rest of the challenge's lifetime. The used nonces live in the distributed cache, atomically where the cache supports it. This is best-effort by the cache's reach: without a configured memcache a reuse is not detected, and with a server-local cache it is detected per server. The guarded flows keep working in both cases. + +Every refused proof MUST leave a log entry naming the user, the route, the purpose and the reason, because the session-only attacker the guard exists for is exactly the caller that produces refusals. + +#### Scenario: A proof cannot be used twice +@e2e exclude Server-side nonce consumption; covered by PHPUnit on VaultKeyProofService. +- **GIVEN** a proof that verified and authorised an operation +- **WHEN** the same nonce and signature are presented again within the challenge's lifetime +- **THEN** the system MUST refuse with `403` and `error: key_proof_required` +- **AND** MUST NOT perform the operation again + +#### 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, or escrow the private key to another party, 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/health-passphrase-generator/design.md b/openspec/changes/health-passphrase-generator/design.md new file mode 100644 index 000000000..54cf4bea6 --- /dev/null +++ b/openspec/changes/health-passphrase-generator/design.md @@ -0,0 +1,48 @@ +# Design: a passphrase mode for the key generator + +## Context + +At development `4c214a9d`: + +- `appinfo/routes.php:71` routes `POST /api/v1/generate-key` to `KeyGeneratorController::generate()` (`lib/Controller/KeyGeneratorController.php:80-100`, parameters `length`, `includeSpecialCharacters`, `excludedCharacters`, `regex`). +- `lib/Service/KeyGeneratorService.php:39-88` holds the limits (length 8 to 128) and character sets; `:110-125` `policy()` reads the organisation policy (`policy_enabled`, `generator_min_length`, `generator_require_upper`, `_lower`, `_digit`, `_symbol`); `:179-195` `generate()` chooses regex or charset mode. +- `openspec/specs/org-password-policies/spec.md:36-44` "Generator Locked to Policy": the server clamps the generator so it never emits a value below the floor or missing a required class; this enforcement is server-authoritative. +- `openspec/specs/key-generator/spec.md:16-29` lists the configuration fields; `:107-113` the frontend integration through the generator modal. +- `src/dialogs/KeyGeneratorModal.vue:143-250` calls the endpoint and emits the value to the create or edit dialog. +- `src/components/settings/OrgPasswordPolicySection.vue` edits the policy. + +## Goals / Non-Goals + +**Goals** +- Memorable passphrases of known strength, from the same generator and under the same policy. + +**Non-Goals** +- Word lists in other languages. A Dutch list is added only when a list of at least 7,776 words under a licence compatible with EUPL-1.2 and REUSE is found; this change does not choose one. +- Generating in the browser. See D1. + +## Decisions + +**D1. Keep generation on the server, where the generator and the policy clamp already are.** The `org-password-policies` spec makes policy enforcement server-authoritative, and the existing generator is server-side by the `key-generator` spec. A second, browser-side generator would split that. Alternative: a browser-only passphrase generator. Rejected for this change; if Keepiq moves all generation into the browser, it moves both modes together in its own change. + +**D2. The EFF large word list, uniform draws.** 7,776 words, about 12.9 bits each, drawn with `random_int()` over the list index. The minimum of 4 words gives about 51.7 bits before any capital, digit or separator. The list is shipped as a resource with its CC BY 3.0 US licence recorded in `LICENSES/` and `REUSE.toml`. + +**D3. The policy is met by extending, not by rejecting.** When the policy is on: add words until the length floor is met; capitalise when an upper-case class is required; add one digit when a digit is required; use a random symbol from the OWASP set as separator when a symbol is required. The result is still checked by the existing class checks. `generator_allow_passphrase` (default true) lets an administrator switch the mode off; the endpoint then answers 400 for `mode: "passphrase"`. + +**D4. The API keeps its old default.** Without `mode`, the endpoint behaves as today (`mode: "password"`). + +## Security and zero-knowledge + +A generated passphrase exists on the server for one response, exactly as a generated password does today, and is neither stored nor logged. It is then encrypted in the browser like any typed value when the secret is saved. The strength shown is the existing client-side zxcvbn meter. + +## Risks / Trade-offs + +- A four-word passphrase is weaker than a 20-character random string. The dialog shows the strength and the default is five words. +- The word list adds about 60 KB to the app package. + +## Seed data + +Keepiq owns its tables (keepiq ADR-001) and has no OpenRegister register. No fixture is needed. + +## Migration + +None. One new app config key with a default. diff --git a/openspec/changes/health-passphrase-generator/proposal.md b/openspec/changes/health-passphrase-generator/proposal.md new file mode 100644 index 000000000..f61ea4a65 --- /dev/null +++ b/openspec/changes/health-passphrase-generator/proposal.md @@ -0,0 +1,51 @@ +--- +kind: code +--- + +# A passphrase mode for the key generator + +## Why + +Keepiq's generator makes character strings: random characters from a set, or a string shaped by a regular expression (`lib/Service/KeyGeneratorService.php:179-195`, `POST /api/v1/generate-key` at `appinfo/routes.php:71`, `src/dialogs/KeyGeneratorModal.vue:203-226`). For the passwords people type (a laptop login, a Wi-Fi key read out to a visitor, a master password for a colleague's new vault) a string of words is easier to type and to remember at the same strength. `docs/FEATURES.md:125` lists "Passphrase generation (word-based), Diceware-style passphrases" as a V1 feature. Every competitor in the matrix has it. + +### Matrix rows (keepiq `openspec/parity/capabilities.json`) + +| row | capability | Keepiq today | +|---|---|---| +| `health-08` | Generate a memorable passphrase made of words. | `no`: the generator only produces character strings or regex-shaped ones; there is no word mode | + +### Demand + +No demand row. Five competitors rate it yes. + +### Competitors rated yes + +- Bitwarden: "libs/tools/generator/core/src/metadata/password/eff-word-list.ts passphrase from the EFF word list Note: Passphrase generator with word count, separator, capitalisation and number options." +- 1Password: Memorable Passwords from words, including Dutch (https://support.1password.com/generate-website-password/). +- Passbolt: "src/shared/lib/SecretGenerator/SecretGenerator.js:28 passphrase type; ... PassphraseGeneratorWords.js word list; ... ConfigurePassphraseGenerator.js:115 number of words." +- Keeper: "generate a highly secure, random, yet easy-to-remember passphrase" (https://docs.keeper.io/user-guides/web-vault#passphrase-generator). +- Nextcloud Passwords: "src/lib/Provider/Words/LocalWordsProvider.php, LeipzigCorporaProvider.php, SnakesWordsProvider.php, AutoWordsProvider.php word sources; src/lib/Controller/Api/ServiceApiController.php:106 wordsService->getPassword." + +## What Changes + +- **A passphrase mode.** `POST /api/v1/generate-key` accepts `mode: "passphrase"` with a word count (4 to 12, default 5), a separator, whether to capitalise each word, and whether to add a digit. Words are drawn uniformly with a cryptographically secure random source from the EFF large word list (7,776 words). +- **In the generator dialog.** `KeyGeneratorModal.vue` gets a Password or Passphrase switch with the passphrase options and shows the passphrase's strength with the existing meter. +- **Policy still wins.** When the organisation password policy is on, a passphrase is extended until it meets the policy's length floor and gets a digit, a capital or a symbol separator when the policy requires that class. An administrator can switch passphrases off in the policy section. + +## Capabilities + +### New Capabilities + +- `passphrase-generator`: word-based passphrases from the key generator, bounded by the organisation password policy. + +### Modified Capabilities + +- None in delta form. `key-generator` and `org-password-policies` keep their requirements; this change's requirements add the mode and state how the policy applies to it. + +## Impact + +- **Backend**: `KeyGeneratorService` (a passphrase branch and the policy fit), `KeyGeneratorController` (the new parameters), a word list resource under `lib/Resources/` with its licence recorded for REUSE, `generator_allow_passphrase` in the policy settings. +- **Frontend**: `KeyGeneratorModal.vue`, `OrgPasswordPolicySection.vue`. +- **Database**: none. +- **Security**: the same exposure as today's generator, which runs on the server by the `key-generator` spec: the value is returned once and never stored or logged. +- **Cross-app**: none. diff --git a/openspec/changes/health-passphrase-generator/specs/passphrase-generator/spec.md b/openspec/changes/health-passphrase-generator/specs/passphrase-generator/spec.md new file mode 100644 index 000000000..04f62e8b8 --- /dev/null +++ b/openspec/changes/health-passphrase-generator/specs/passphrase-generator/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Generate a passphrase + +The key generator MUST accept `mode: "passphrase"` on `POST /api/v1/generate-key` with `words` (4 to 12, default 5), `separator` (default `-`), `capitalise` (default false) and `includeNumber` (default false). It MUST draw each word uniformly from the EFF large word list of 7,776 words with a cryptographically secure random source. A request with fewer than 4 or more than 12 words MUST be rejected with 400. Without `mode` the generator MUST behave as before. + +#### Scenario: A vault user generates a passphrase for a new secret + +- **GIVEN** a vault user creating a secret in the new secret dialog on /secrets +- **WHEN** the user opens the generator, switches to Passphrase, picks 6 words with a space as separator and generates +- **THEN** the value field holds six words from the list separated by spaces + +#### Scenario: Too few words + +- **GIVEN** a signed-in user +- **WHEN** the user calls `POST /api/v1/generate-key` with `mode: "passphrase"` and `words: 3` +- **THEN** the response is 400 and names the allowed range + +### Requirement: Passphrases follow the organisation password policy + +When the organisation password policy is on, a generated passphrase MUST meet the policy's length floor and required character classes, by adding words, capitalising, adding a digit or using a symbol separator. An administrator MUST be able to switch passphrases off in the organisation policy section; the endpoint MUST then reject `mode: "passphrase"` with 400. + +#### Scenario: The policy requires a digit and 30 characters + +- **GIVEN** an organisation policy with a length floor of 30 and a required digit +- **WHEN** a user generates a passphrase of 4 words +- **THEN** the passphrase is at least 30 characters long and contains a digit + +#### Scenario: An administrator switches passphrases off + +- **GIVEN** an administrator who switched off passphrases in the organisation policy section of the Keepiq admin settings +- **WHEN** a user requests a passphrase +- **THEN** the response is 400 and the generator dialog offers Password only diff --git a/openspec/changes/health-passphrase-generator/tasks.md b/openspec/changes/health-passphrase-generator/tasks.md new file mode 100644 index 000000000..da3b57063 --- /dev/null +++ b/openspec/changes/health-passphrase-generator/tasks.md @@ -0,0 +1,21 @@ +# Tasks: a passphrase mode for the key generator + +## 1. Backend + +- [ ] 1.1 Add the EFF large word list under `lib/Resources/` with its licence in `LICENSES/` and `REUSE.toml`. Verify: the REUSE compliance check and a PHPUnit that the list loads 7,776 unique words. +- [ ] 1.2 Add the passphrase branch to `KeyGeneratorService` (word count 4 to 12, separator, capitalise, add digit) and the parameters to `KeyGeneratorController::generate()`. Verify: PHPUnit for bounds, separator handling and a 400 below 4 words. +- [ ] 1.3 Fit the passphrase to the organisation policy (D3) and add `generator_allow_passphrase`. Verify: PHPUnit with each required class and a length floor above the natural length, and a 400 when the mode is switched off. + +## 2. Frontend + +- [ ] 2.1 Add the Password or Passphrase switch and options to `KeyGeneratorModal.vue`. Verify: vitest on the request body per mode, and a Playwright flow generate a passphrase into a new secret. +- [ ] 2.2 Add the passphrase switch to `OrgPasswordPolicySection.vue`. Verify: vitest that the setting is saved, and `npm run lint`. + +## 3. Docs + +- [ ] 3.1 Document the passphrase mode and how the policy applies to it. Verify: docs build. + +## Acceptance criteria + +- A user generates a passphrase of 4 to 12 words with a chosen separator, capitals and a digit, and it lands in the secret's value field. +- With the organisation policy on, every passphrase meets the policy's floor and classes, and an administrator can switch the mode off. diff --git a/openspec/changes/health-site-security-checks/design.md b/openspec/changes/health-site-security-checks/design.md new file mode 100644 index 000000000..cc21314ee --- /dev/null +++ b/openspec/changes/health-site-security-checks/design.md @@ -0,0 +1,49 @@ +# Design: site checks in the password health report + +## Context + +At development `4c214a9d`: + +- `src/health/engine.js:95-175` builds findings per row with flags `weak`, `reused`, `stale`, `compromised` and `breached`; rows come from `src/store/modules/health.js:159` `loadDecryptedRows()`, which excludes authenticator seeds, and the engine runs in `src/health/worker.js`. +- `src/views/HealthReportView.vue:95-153` renders one `HealthCategory` per flag. +- `openspec/specs/password-health/spec.md:111-117` "No Server-Side Health Knowledge": no endpoint accepts scores, digests, reuse data or verdicts. +- `lib/Controller/BreachProxyController.php:51-57,155` proxies the Have I Been Pwned range API with `IClientService`, gated by `breach_check_enabled` (`lib/Service/AdminSettingsService.php:149,349`), default off, edited in `src/components/settings/BreachCheckSection.vue`. +- Passkeys are items of the `passkey` type holding the credential and its relying party id (`openspec/specs/passkey-item-type/`); authenticator seeds are `totp` items or, after `vault-login-totp-codes`, a `totp` key on a login. +- The extension fills through `browser-extension/src/background/service-worker.js:113-145` `doFill()`. + +## Goals / Non-Goals + +**Goals** +- Tell the user, per login, where a site offers stronger sign-in than they use, and where the saved address is not encrypted. +- Keep every vault fact in the browser. + +**Non-Goals** +- An organisation-wide view. The password-health spec forbids the server knowing any of this (`health-13` is decided no on that record). +- Checking whether a site redirects http to https. A saved `http://` address is flagged as saved. + +## Decisions + +**D1. The http check is a pure client-side rule.** Any address of a login starting with `http://`, except `localhost`, loopback and private-network hosts, is flagged. The extension warns before filling on a page whose own URL is `http://`. + +**D2. The site directory is fetched whole by the server and matched in the browser.** Querying a directory per host would tell its operator which sites the user has. Downloading the whole list does not. The server fetches it, as the breach check reaches HIBP through the instance's own proxy, so the browser talks only to the Keepiq instance: Nextcloud's default content security policy limits an app page's connections to its own origin, and one cached copy serves every user. It stores the list in app data, refreshes it daily, and serves it at `GET /api/v1/site-directory` with a hash so the browser caches it. The first task confirms the directory's endpoints and licence (the 2FA Directory at 2fa.directory publishes TOTP and passkey support) and records them here before any code is written. + +**D3. Gate it like breach checking.** `site_directory_enabled`, default off, in the admin section next to breach checking. With it off, the two directory categories show as unavailable and the http category still works. + +**D4. "Has a second factor" and "has a passkey" are vault facts.** A login has a second factor when it carries a seed or an authenticator item matches its host; it has a passkey when a passkey item's relying party id matches its registrable domain. Matching reuses the extension's registrable-domain helper, moved to a shared module. + +## Security and zero-knowledge + +The server sends a public list and receives nothing from the user beyond the authenticated request for that list. Matching, the categories and their counts stay in the browser and are dropped on lock, keeping "No Server-Side Health Knowledge". The download of the list does not depend on what is in any vault. + +## Risks / Trade-offs + +- A stale or wrong directory entry produces a wrong finding. The finding links to the directory's entry so the user can check it. +- The directory licence may require attribution; the admin section and the report carry it. + +## Seed data + +Keepiq owns its tables (keepiq ADR-001) and has no OpenRegister register. The dev fixture vault gets one login with an `http://` address, and the test suite ships a small directory fixture with one TOTP site and one passkey site, so all three categories show without a network. + +## Migration + +None. One app config key with a default, and a file in app data created by the job. diff --git a/openspec/changes/health-site-security-checks/proposal.md b/openspec/changes/health-site-security-checks/proposal.md new file mode 100644 index 000000000..4a1c7a3a1 --- /dev/null +++ b/openspec/changes/health-site-security-checks/proposal.md @@ -0,0 +1,60 @@ +--- +kind: code +--- + +# Site checks in the password health report: unused two-factor, plain http and passkeys + +## Why + +The password health report looks at the values only. The engine flags weak, reused, stale, compromised and breached secrets (`src/health/engine.js:114-175`) and the report shows those categories plus rotation (`src/views/HealthReportView.vue:95-153`). It says nothing about the site a login belongs to: whether that site offers two-factor login the user has not set up, whether the saved address is plain `http://` so the password crosses the network in the clear, and whether the site accepts a passkey the user could switch to. All three can be answered in the browser from the login's plain-text address and the user's own vault, without teaching the server anything. + +The three rows share one screen, the password health report, and one service, the health engine. + +### Matrix rows (keepiq `openspec/parity/capabilities.json`) + +| row | capability | Keepiq today | +|---|---|---| +| `health-11` | Be told which websites offer two-factor login you have not switched on. | `no`: no inactive two-factor report; the engine has no such category | +| `health-12` | Be warned about logins that use an unencrypted http address. | `no`: no insecure-address finding in the report or the extension | +| `health-15` | See which of your logins are for websites that accept a passkey you have not set up. | `no`: the report has no passkey-available check | + +### Demand + +- `health-15`: changelog, https://github.com/bitwarden/clients/pull/22766 +- `health-11`, `health-12`: no demand row; two and three competitors rate them yes. + +### Competitors rated yes + +- `health-11`, Bitwarden: "apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts:98 loads the 2fa directory and flags logins on sites supporting TOTP without a TOTP seed ... Inactive two-step login report for personal and organisation vaults." +- `health-11`, 1Password: shows "logins for websites that support two-factor authentication" (https://support.1password.com/watchtower/). +- `health-12`, Bitwarden: "apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.ts:136 flags URIs starting with 'http://' Note: Unsecured websites report." +- `health-12`, 1Password: "'Unsecured websites' catches HTTP sites" (https://support.1password.com/watchtower/). +- `health-12`, Keeper: KeeperFill setting "Enforce the HTTP Fill Warning popup" (https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#keeperfill). +- `health-15`, Bitwarden: "apps/web/src/app/dirt/reports/pages/passkey-report.service.ts:31 loadPasskeyDirectory via PasskeyDirectoryApiService, :74 skips logins that already have hasFido2Credentials ... It is behind the PasskeyLoginReport feature flag." +- `health-15`, 1Password: "Passkeys available shows logins for websites that support passkeys, but don't yet have a passkey saved in the item." (https://support.1password.com/watchtower/). + +## What Changes + +- **Plain http.** A new health category, Unencrypted address, lists logins whose main or extra address starts with `http://`, with a Change to https action that edits the address. The browser extension shows a warning before filling on an `http://` page. +- **Unused two-factor.** A new category lists logins for sites that support one-time codes when the vault holds no seed for that login: neither a seed on the login (`vault-login-totp-codes`) nor an authenticator item for the same host. +- **Passkey available.** A new category lists logins for sites that accept passkeys when the vault holds no passkey item for that site. +- **A site directory, fetched whole.** The two-factor and passkey checks read a public directory of sites. When an administrator switches the site directory on (off by default), a daily job on the server downloads the whole directory, and the browser downloads it from Keepiq and matches locally. No login address leaves the browser. + +## Capabilities + +### New Capabilities + +- `health-site-checks`: three site-based health categories, computed in the browser, and an admin-gated site directory the server fetches whole. + +### Modified Capabilities + +- None in delta form. `password-health` keeps "No Server-Side Health Knowledge": the server serves a public list and learns nothing about the vault. + +## Impact + +- **Backend**: `site_directory_enabled` in `AdminSettingsService` (default off), a `RefreshSiteDirectoryJob` that fetches the directory through `IClientService` into app data, and `GET /api/v1/site-directory` that serves it. +- **Frontend**: three categories in `src/health/engine.js` and `HealthReportView.vue`, a site directory switch in the breach checking admin section or next to it. +- **Browser extension**: an http warning before a fill. +- **Database**: none. The directory is a file in app data. +- **Security**: the server fetches a public list and never receives a host from a user; the browser matches locally. +- **Cross-app**: when `adopt-connection-registry` has landed, the directory is declared as a connection next to `hibp`. diff --git a/openspec/changes/health-site-security-checks/specs/health-site-checks/spec.md b/openspec/changes/health-site-security-checks/specs/health-site-checks/spec.md new file mode 100644 index 000000000..b70ff2d85 --- /dev/null +++ b/openspec/changes/health-site-security-checks/specs/health-site-checks/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Flag logins saved with an unencrypted address + +The password health report MUST list, in an Unencrypted address category, every login whose main or extra address starts with `http://`, except addresses on `localhost`, loopback or private-network hosts, and MUST offer a Change to https action that edits the address. The browser extension MUST ask for confirmation before filling a login on a page whose address starts with `http://`. + +#### Scenario: A vault user finds a login saved with http + +- **GIVEN** a vault user whose login for the intranet is saved as `http://intranet.example.org` +- **WHEN** the user opens the password health report at /password-health +- **THEN** the login is listed under Unencrypted address +- **AND** choosing Change to https saves the address as `https://intranet.example.org` + +#### Scenario: The extension warns on an http page + +- **GIVEN** an unlocked browser extension on a sign-in page served over `http://` +- **WHEN** the user picks a login to fill +- **THEN** the extension asks for confirmation before it fills + +### Requirement: Flag unused two-factor login and available passkeys + +When the site directory is switched on, the password health report MUST list, in a Two-factor available category, every login for a site the directory marks as supporting one-time codes when the vault holds no seed for that login, neither on the login nor as an authenticator item matching its host. It MUST list, in a Passkey available category, every login for a site the directory marks as supporting passkeys when the vault holds no passkey item whose relying party id matches the login's registrable domain. The matching MUST run in the browser. When the site directory is switched off, both categories MUST show as unavailable. + +#### Scenario: A vault user sees a site that offers one-time codes + +- **GIVEN** the site directory is on and lists `github.com` as supporting one-time codes +- **AND** a vault user whose login for `github.com` has no seed and no authenticator item matches it +- **WHEN** the user opens the password health report +- **THEN** the login is listed under Two-factor available + +#### Scenario: A passkey already saved + +- **GIVEN** the site directory lists `example.com` as supporting passkeys +- **AND** the vault holds a passkey item for relying party `example.com` +- **WHEN** the user opens the password health report +- **THEN** the login for `example.com` is not listed under Passkey available + +### Requirement: The site directory is fetched whole and taught nothing + +The system MUST fetch the site directory only when an administrator has switched on `site_directory_enabled`, which MUST be off by default. The server MUST download the whole directory in a daily background job, store it in app data and serve it at `GET /api/v1/site-directory` to signed-in users. No endpoint MUST accept a host, login address, flag or count from the browser for these checks. When a refresh fails, the last good copy MUST be kept. + +#### Scenario: The directory is off by default + +- **GIVEN** a fresh instance +- **WHEN** a signed-in user calls `GET /api/v1/site-directory` +- **THEN** the response is 404 and no request to the directory's operator has been made + +#### Scenario: A failed refresh keeps the last copy + +- **GIVEN** the site directory is on and a copy was stored yesterday +- **WHEN** today's refresh fails with a network error +- **THEN** `GET /api/v1/site-directory` still serves yesterday's copy diff --git a/openspec/changes/health-site-security-checks/tasks.md b/openspec/changes/health-site-security-checks/tasks.md new file mode 100644 index 000000000..740416736 --- /dev/null +++ b/openspec/changes/health-site-security-checks/tasks.md @@ -0,0 +1,27 @@ +# Tasks: site checks in the password health report + +## 1. Plain http + +- [ ] 1.1 Add the `insecure-address` flag to `src/health/engine.js` (D1 exclusions) and its category in `HealthReportView.vue` with a Change to https action. Verify: vitest for http, https, localhost and a private address; Playwright check on the fixture login. +- [ ] 1.2 Warn before a fill on an `http://` page in the extension. Verify: extension unit test that the fill waits for confirmation on http and not on https. + +## 2. Site directory + +- [ ] 2.1 Confirm the directory endpoints and licence, and record them in this design. Verify: the design names both URLs and the licence, reviewed in the PR. +- [ ] 2.2 Add `site_directory_enabled` (default off), `RefreshSiteDirectoryJob` (daily, `IClientService`, stored in app data) and `GET /api/v1/site-directory` with an ETag. Verify: PHPUnit with a mocked client for refresh, a failed fetch keeping the old file, and 404 when switched off; hydra route-auth gate. +- [ ] 2.3 Add the switch with the privacy and attribution text to the admin settings. Verify: Playwright check of the section. + +## 3. Directory categories + +- [ ] 3.1 Move the registrable-domain helper from `browser-extension/src/lib/match.js` to a module both the web app and the extension import. Verify: the existing extension match tests pass unchanged. +- [ ] 3.2 Add the `two-factor-available` and `passkey-available` flags to the engine using the directory and the vault facts of D4, and their categories in the report. Verify: vitest with the directory fixture, a login with and without a seed, and with and without a passkey item. + +## 4. Docs + +- [ ] 4.1 Document the three categories and the site directory setting in `docs/password-health.md`. Verify: docs build. + +## Acceptance criteria + +- Logins saved with a plain http address are listed and can be changed to https in one action; the extension warns before filling on an http page. +- With the site directory on, logins for sites that offer one-time codes or passkeys the user has not set up are listed. +- No login address, flag or count is sent to the server. 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..82a075bd1 --- /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 that were not carried: an unreachable grantee, or, since keepiq#800, one the owner did not tick or with a break-glass in flight — is invalidated as today, but surfaced to the owner instead of lost silently, with a prompt to re-designate only an unreachable one (keepiq#804) +- 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 contacts that were not carried, because the carried ones already left the old suite. Since keepiq#800 that is every contact the browser did not carry: an unreachable grantee, one the owner did not tick, and one whose break-glass was in flight (recorded as `grantor_rotation_in_flight`). The listener keeps its current code; its meaning narrows from "invalidate all" to "invalidate whatever the loop did 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 an unreachable contact is re-designatable and the failure mode is "prompt to re-establish", never data loss. Only an unreachable contact gets that prompt: an unconfirmed or in-flight one is named without it (keepiq#804). + +### 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 the unreachable ones, and names unconfirmed and in-flight ones without that prompt (keepiq#804). 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..0b9fb4e46 --- /dev/null +++ b/openspec/changes/migrate-emergency-access-on-rotation/plan.json @@ -0,0 +1,290 @@ +{ + "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 did not carry (grantee unreachable, not ticked by the owner, or break-glass in flight \u2014 keepiq#800). 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 only unreachable contacts; name unconfirmed and in-flight contacts, and those a resumed rotation removed, without that prompt; 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": 30, + "num": "3.4", + "title": "After a resumed rotation completes (also by accepting a loss), read back the contacts the sweep removed and name them on the completion screen; the resume banner raises a permanent toast with the count and a pointer to Emergency Access (keepiq#804)", + "status": "pending", + "spec_ref": null, + "acceptance_criteria": [], + "files_likely_affected": [] + }, + { + "id": 31, + "num": "3.5", + "title": "The Emergency Access view offers no Re-establish for a rotation-invalidated contact and shows a text-only notice on it, with a warning for an in-flight one (keepiq#804)", + "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..105a7454d --- /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 were not re-enveloped (grantee has no active suite / left the instance, or, since keepiq#800, not ticked by the owner or with a break-glass in flight), which are exactly the ones that genuinely must be invalidated +- Surface the residual: where any contact was invalidated rather than migrated, tell the owner which contact was removed, and prompt re-designating only an unreachable one (keepiq#804) — 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 the residual contacts; `CompromiseRecoveryForm.vue` prompts re-designating the unreachable ones and names the others without that prompt (keepiq#804) +- **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..0db56a3c2 --- /dev/null +++ b/openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md @@ -0,0 +1,112 @@ +## 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 tell the grantor it was removed, as set out below. 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. + +Carrying a contact hands the grantor's **new** key to that grantee, and a compromise recovery runs precisely when someone else may have held the grantor's session. So the carry MUST be the grantor's explicit choice, not a side effect (keepiq#800): + +- Before the rotation starts, the system MUST show the grantor the contacts that can be carried and MUST carry only the ones the grantor confirms. None MUST be preselected. +- Only a contact in state `granted` MUST be carried. A contact with a break-glass `requested` or `approved` MUST NOT be carried: the server MUST refuse it, and it is left on the old suite for the completion sweep to invalidate. The grantor re-designates it if they still want it. +- Each re-envelope MUST carry a verified key proof made with the migration's **new** key (see the `vault-key-proof` capability), because it overwrites the contact's envelope. Not the old key: every migration is a compromise recovery, and the old password may be the leaked one. The new key is held by the party who started the migration, so this rules out a leaked password used against a rotation the owner started, but not a rotation the holder of the session and old password started themselves: starting one is proven with the active key. +- A carry MUST be audited as its own event, distinct from a fresh designation, so a carry cannot be mistaken for a planted designation after an incident. + +Contacts the grantor did not confirm, or that were refused, are invalidated at completion like any other residual contact. The completion sweep records `grantor_rotation_in_flight` for a contact whose break-glass was in flight and `grantor_rotation` for every other residual contact. It MUST NOT guess the grantor's choice from reachability. The grantor MUST be told about every contact a rotation removed, however the rotation reached completion: + +- **A rotation started from the recovery form** knows which contacts the grantor ticked, also when the form completes it by retrying or by accepting a loss. Its completion screen MUST prompt the grantor to re-establish an unreachable contact (no active certificate, or a failed re-envelope), and MUST name unconfirmed and in-flight contacts without that prompt. +- **A resumed rotation** carries no contact and doesn't know the ticks. After completion, including a completion by accepting a loss, it MUST read back the contacts the sweep removed. The recovery form's completion screen MUST name them neutrally, without a prompt to re-establish, and the resume banner MUST say how many were removed and point to Emergency Access. +- **The Emergency Access view** MUST NOT offer to re-establish any contact a rotation invalidated. It MUST show a text-only notice on each one that the rotation removed it, and MUST warn about one whose break-glass was in flight. This also covers a completion screen that was closed unread. A `declined` contact has nothing in flight and counts as not confirmed. The system MUST NOT prompt the grantor to re-establish a contact they did not confirm, and MUST warn, rather than prompt, about a contact whose break-glass was in flight, because that is what a planted contact looks like. + +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 in state `granted` whose EncryptionSuite is active +- **AND** a recovery envelope escrowing A's current private key +- **WHEN** A performs compromise recovery, confirms that B is to be carried, 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: A contact with a break-glass in flight is not carried +@e2e exclude Server-side state refusal and listener sweep; covered by PHPUnit on EmergencyEnvelopeInvalidationService and the completion sweep. +- **GIVEN** A has an emergency contact B whose break-glass is `requested` or `approved` +- **WHEN** A performs compromise recovery and rotates their EncryptionSuite +- **THEN** the system MUST NOT escrow A's new private key to B +- **AND** B MUST be invalidated at completion, and A MUST be warned about B rather than prompted to re-establish B + +#### Scenario: An unconfirmed contact is not carried +@e2e exclude The confirmation list is component state; covered by vitest on CompromiseRecoveryForm and the store. +- **GIVEN** A has emergency contacts B and C, both `granted` with active suites +- **WHEN** A performs compromise recovery and confirms only B +- **THEN** only B MUST receive an envelope escrowing A's new private key +- **AND** C MUST be invalidated at completion, without a prompt to re-establish C +- **AND** the Emergency Access view MUST NOT offer to re-establish C afterwards + +#### Scenario: The Emergency Access view warns about an in-flight contact +@e2e exclude The view renders server-recorded reasons; covered by vitest on EmergencyAccessView and PHPUnit on the completion sweep. +- **GIVEN** A's contact B was invalidated at completion because its break-glass was in flight +- **WHEN** A opens the Emergency Access view +- **THEN** B MUST be shown with a warning and without a Re-establish action + +#### 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, on the recovery form's completion screen +- **AND** the Emergency Access view MUST NOT offer to re-establish C afterwards + +#### Scenario: Retrying from the recovery form keeps the grantor's choices +@e2e exclude The residual list is component state; covered by vitest on CompromiseRecoveryForm. +- **GIVEN** A started compromise recovery from the recovery form, left contact C unticked, and a record failed +- **WHEN** A retries from the form and the rotation completes +- **THEN** the completion screen MUST name C as not confirmed, without a prompt to re-establish C +- **AND** MUST NOT describe the rotation as resumed + +#### Scenario: A resumed rotation names the contacts it removed +@e2e exclude Client-side read-back after completion; covered by vitest on the store, CompromiseRecoveryForm, MigrationResumeBanner and EmergencyAccessView. +- **GIVEN** A's rotation was interrupted before any emergency contact was carried, and A has contact B, `granted`, on the old suite +- **WHEN** A resumes the rotation and it completes +- **THEN** B MUST be invalidated at completion +- **AND** A MUST be told that B was removed, on the completion screen or, from the resume banner, as a count pointing to Emergency Access +- **AND** A MUST NOT be prompted to re-establish B +- **AND** the Emergency Access view MUST show that a key rotation removed B, with no re-establish action + +#### Scenario: A resumed rotation finished by accepting a loss names the contacts it removed +@e2e exclude Client-side read-back after the loss acknowledgement; covered by vitest on the store and CompromiseRecoveryForm. +- **GIVEN** A resumed an interrupted rotation, a record still failed, and A has contact B, `granted`, on the old suite +- **WHEN** A accepts the loss and the rotation completes +- **THEN** B MUST be invalidated at completion +- **AND** the recovery form's completion screen MUST name B, without a prompt to re-establish B + +#### 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..47188b59c --- /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`. Every other contact on the old suite (unreachable, not confirmed, or with a break-glass in flight) is invalidated at completion, and the grantor is told which ones were removed (see the `emergency-access` spec). 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 told that this specific contact was removed (see the `emergency-access` spec for where) 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..d19031fee --- /dev/null +++ b/openspec/changes/migrate-emergency-access-on-rotation/tasks.md @@ -0,0 +1,58 @@ +## 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 did not carry (grantee unreachable, not ticked by the owner, or break-glass in flight — keepiq#800). 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 only unreachable contacts; name unconfirmed and in-flight contacts, and those a resumed rotation removed, without that prompt; 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 +- [x] 3.4 After a resumed rotation completes (also by accepting a loss), read back the contacts the sweep removed and name them on the completion screen; the resume banner raises a permanent toast with the count and a pointer to Emergency Access (keepiq#804) +- [x] 3.5 The Emergency Access view offers no Re-establish for a rotation-invalidated contact and shows a text-only notice on it, with a warning for an in-flight one (keepiq#804) + +## 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/changes/portability-export-choice-and-restore-fidelity/design.md b/openspec/changes/portability-export-choice-and-restore-fidelity/design.md new file mode 100644 index 000000000..ab77513f2 --- /dev/null +++ b/openspec/changes/portability-export-choice-and-restore-fidelity/design.md @@ -0,0 +1,54 @@ +# Design: choose what goes into an export, and restore a backup without losing types + +## Context + +At development `4c214a9d`: + +- `src/views/SecretList.vue:1224-1239` `decryptAllSecrets()` fetches the whole vault, decrypts each secret and skips, in silence, any it cannot decrypt; `openExport()` (`:1247`) hands the result to `ExportDialog.vue` (`:8-11`). +- `src/dialogs/ExportDialog.vue:250-257` builds scope options (entire vault or one folder); `:322-327` `buildScope()` returns `{ mode: 'vault' }` or `{ mode: 'folders', folderIds: [one] }`; the modes are encrypted backup, plaintext CSV and CXF (`src/store/modules/export.js:86`, `:122`, `:160`). +- `src/export/serializer.js:67-87` `collectSubtree()` and `:101-130` `serializeVault()`, which writes `type: secret.type ?? secret.typeId ?? 'login'` (`:120`). +- A decrypted secret is `{ ...secret }` with `typeId` (`src/store/modules/secret.js:306`, `lib/Db/Secret.php:307`); system type ids are deterministic UUID v5 of the type name (`lib/Repair/SeedSecretTypes.php:45-60`), custom type ids are random. +- `src/import/backupParser.js:23-35` `toRows()` copies `type` through and sets no `sourceRow`; CSV rows get one (`src/import/parsers/csv.js:83-102`). +- `src/store/modules/import.js:251-288` `encryptRow()` stamps `typeId` only for `totp`, `passkey`, `card` and `identity` by name; everything else is stored with the default type. +- `appinfo/routes.php:74-77` has `secretType#index` and `secretType#create` (user-scoped custom types). +- `tests/vitest/export-serializer.spec.js:24-26` uses `typeId: 'login'`, a name, not a UUID. + +## Goals / Non-Goals + +**Goals** +- Export exactly the part of the vault the user means. +- A backup restores every secret with its type, and every rejected row can be found. +- An export never drops a secret without saying so. + +**Non-Goals** +- Attachments in the backup. They stay out, as today, and the dialog keeps saying so. +- A server-side or scheduled backup (`admin-scheduled-vault-backups` covers that). + +## Decisions + +**D1. Scope is a set of filters that combine.** `{ folderIds?, typeIds?, secretIds? }`; an empty scope is the entire vault. A selection scope (`secretIds`) comes from the bulk strip and cannot be combined with the others in the dialog, to keep the dialog simple. `serializeVault()` applies the filters in one pass after `collectSubtree()`. + +**D2. Field choice is a deny list with fixed minimums.** The user may leave out `login`, `additionalFields` or the `totp` seed inside additional fields. Name and value always go in, because a backup without values is not a backup. The payload header gets `partial: true` and the list of left-out fields; the restore shows it before committing. + +**D3. The export writes names, the restore resolves names.** Each exported secret gets `typeName` (the type's `name`) and, for a custom type, `typeLabel`, next to the existing `type`. Restore resolves in order: a system type by `typeName`; an existing custom type with the same name; a new user-scoped custom type created through `POST /api/v1/secret-types`; the default type with a row warning. For backups written before this change, where `type` holds a UUID, the restore tries the UUID against the instance's type ids first, which recovers system types because their ids are the same on every instance. + +**D4. Source rows are positions in the payload.** `toRows()` sets `sourceRow` to the 1-based index, so the import's rejection list and duplicate step name the row. + +**D5. The skipped count travels with the export.** `decryptAllSecrets()` returns `{ secrets, skipped }`; the dialog shows "N secrets could not be decrypted and are not in this export" and asks the user to continue. + +## Security and zero-knowledge + +All of it runs in the browser on data the user already decrypted. Type names, labels and the partial marker sit inside the encrypted backup payload. Creating a custom type on restore sends only the type's name and label, the same data the existing type editor sends. The plaintext CSV keeps its master password check and warning (`ExportDialog.vue:130`, `src/crypto/reauth.js`). + +## Risks / Trade-offs + +- Restoring creates custom types the user may not want. The restore summary lists them, and the user can delete them afterwards. +- A partial backup can be mistaken for a full one. The header marker and the restore notice exist for that. + +## Seed data + +Keepiq owns its tables (keepiq ADR-001) and has no OpenRegister register. The dev fixture vault gets one API key, one authenticator and one secret of a custom type "VPN profile", so a round trip shows every type branch. + +## Migration + +None. Old backups keep restoring through D3's fallback. diff --git a/openspec/changes/portability-export-choice-and-restore-fidelity/proposal.md b/openspec/changes/portability-export-choice-and-restore-fidelity/proposal.md new file mode 100644 index 000000000..80840134d --- /dev/null +++ b/openspec/changes/portability-export-choice-and-restore-fidelity/proposal.md @@ -0,0 +1,61 @@ +--- +kind: code +--- + +# Choose what goes into an export, and restore a backup without losing types + +## Why + +The export dialog offers one choice of scope: the entire vault or one folder with its subfolders (`src/dialogs/ExportDialog.vue:250-257` `scopeOptions`, `:322-327` `buildScope`, `src/export/serializer.js:101` `serializeVault` with `collectSubtree`). A user who wants to hand over the logins of one project, or only the API keys, or the selection they just made in the vault list, exports everything or one folder. The bulk strip in the vault list can move, share and delete a selection but cannot export it (`src/views/SecretList.vue:294-333`). + +Restoring a `.doriath-backup` loses the secret types. Reading the code at development `4c214a9d`: the export writes `type: secret.type ?? secret.typeId ?? 'login'` (`src/export/serializer.js:120`), and a decrypted secret carries only `typeId` (`src/store/modules/secret.js:306`, `lib/Db/Secret.php:307`), which is a UUID (`lib/Repair/SeedSecretTypes.php` derives system type ids as UUID v5). The restore passes `type` through unchanged (`src/import/backupParser.js:32`) and the import stamps a type only when `type` is the name `totp`, `passkey`, `card` or `identity` (`src/store/modules/import.js:271-288`). So a restored authenticator, passkey, card, identity, API key or custom-type secret comes back as the default type. The serializer test uses `typeId: 'login'`, a name (`tests/vitest/export-serializer.spec.js:24-26`), so it cannot see this. Restored rows also carry no source row number (`backupParser.js:23-35` sets none), so a rejected row cannot be traced back, and secrets that fail to decrypt are skipped from the export without a word (`src/views/SecretList.vue:1230-1236`). + +### Matrix rows (keepiq `openspec/parity/capabilities.json`) + +| row | capability | Keepiq today | +|---|---|---| +| `portability-09` | Export an encrypted backup that can be restored. | `partial`: client-side encrypted backup and restore through the import wizard; restored rows have no source row number and custom types come back as the default type | +| `portability-11` | Choose what goes into an export. | `partial`: whole vault or one folder subtree; no choice by type, selected items or fields | + +For `portability-09` the missing half is a restore that keeps every secret type and each row's source position, and an export that reports what it could not include. For `portability-11` it is choosing by type, by selected items and by fields. The encrypted backup itself and the folder scope are built. `vault-19` (bulk export of a selection) was deferred on its own row and is covered here as part of `portability-11`. + +### Demand + +No demand row for either. `portability-09` has five competitors rating it yes and `portability-11` three. + +### Competitors rated yes + +- `portability-09`, Bitwarden: "libs/tools/export-vault-core/src/services/individual-vault-export.service.ts:61 encrypted_json; ... export.component.ts:236 fileEncryptionType (account restricted or file password :234); libs/importer/src/importers bitwarden encrypted JSON importer Note: Encrypted JSON export, account-bound or password-protected, re-importable." +- `portability-09`, Passbolt: "ExportResources.js:205 kdbx export formats; ... ExportResourcesCredentials.js:147 password (and key file) protecting the KDBX; ... resourcesKdbxImportParser.js re-import." +- `portability-09`, Keeper: "'Encrypted Keepass (.kdbx)' export protected by a chosen master password or key file, re-importable" (https://docs.keeper.io/user-guides/export-and-reports/vault-export). +- `portability-09`, HashiCorp Vault: "vault/logical_system_raft.go:142 sys/storage/raft/snapshot (save and restore) ... Integrated storage snapshots stay barrier-encrypted and can be downloaded and restored from the UI, even in CE." +- `portability-09`, Nextcloud Passwords: "src/vue/Components/Export.vue:7 'Database Backup' with optional backup password (:20-30, minlength 10); src/js/Manager/ExportManager.js:47-51 encrypts each section with options.password; ... src/lib/Command/BackupRestoreCommand.php:45." +- `portability-11`, Bitwarden: "export.component.ts:111 organizationId selection (personal vault or a chosen organisation), format choice json/csv/encrypted_json/zip ... with or without attachments; not a per-item or per-folder selection." +- `portability-11`, Passbolt: "ExportResources.js:82 exports the selected folders (with subfolders) and :177 selected resources Note: Users export a selection of passwords or folders, or everything." +- `portability-11`, Nextcloud Passwords: "src/vue/Components/Export.vue:42-60 choose passwords, folders, tags; :70 excludeShared; :81-84 CSV database choice ... You choose which object types go in, whether shared items are included, and CSV columns for custom CSV." + +## What Changes + +- **Choose by folders, types and selection.** The export dialog's scope becomes: entire vault, chosen folders (several, with subfolders), chosen secret types, or the current selection. Export selected is added to the vault list's bulk strip and opens the dialog with the selection as scope. +- **Choose fields.** For the plaintext CSV and the encrypted backup, the user can leave out usernames, additional fields or the one-time code seed. The name and the value are always included. A backup with fields left out is marked as partial in its header, and the restore says so. +- **Restore keeps types.** The export writes each secret's type name, and for a custom type its label, next to the type id. The restore maps a system type by name, maps a custom type to an existing type of the same name, and creates a user-scoped custom type through `POST /api/v1/secret-types` when none exists. Only when that fails does a row fall back to the default type, with a warning on that row. +- **Restore keeps positions.** Each restored row gets its 1-based position in the backup as its source row, so a rejected row can be found. +- **Nothing skipped in silence.** When secrets cannot be decrypted for an export, the dialog says how many were left out, before the file is written. + +## Capabilities + +### New Capabilities + +- `export-selection-and-restore`: export scope by folders, types, selection and fields, and a lossless restore of types and row positions. + +### Modified Capabilities + +- None in delta form. `secret-export` and `secret-import` keep their requirements; this change adds requirements next to them. + +## Impact + +- **Backend**: none beyond the existing `POST /api/v1/secret-types` used by the restore. +- **Frontend**: `ExportDialog.vue` (scope and fields), `src/export/serializer.js` (type name and label, field filter, partial marker, skipped count), `src/import/backupParser.js` (type and source row), `src/store/modules/import.js` (type resolution), `SecretList.vue` (Export selected, skipped count). +- **Database**: none. +- **Security**: the backup format stays client-side, Argon2id plus AES-256-GCM (`src/export/backup.js`); type names and labels are inside the encrypted payload. The plaintext CSV keeps its master password check and warning. +- **Cross-app**: none. The CXF export (`cxf-import-export`) keeps its own mapping. diff --git a/openspec/changes/portability-export-choice-and-restore-fidelity/specs/export-selection-and-restore/spec.md b/openspec/changes/portability-export-choice-and-restore-fidelity/specs/export-selection-and-restore/spec.md new file mode 100644 index 000000000..a9a7d2358 --- /dev/null +++ b/openspec/changes/portability-export-choice-and-restore-fidelity/specs/export-selection-and-restore/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Choose what an export contains + +The export dialog MUST let the user limit an encrypted backup, plaintext CSV or CXF export to chosen folders with their subfolders, to chosen secret types, or to the secrets currently selected in the vault list, and MUST offer Export selected in the vault list's selection strip. For the encrypted backup and the plaintext CSV the dialog MUST let the user leave out usernames, additional fields or one-time code seeds, and MUST always include each secret's name and value. A backup with fields left out MUST be marked partial inside its encrypted payload. + +#### Scenario: A vault user exports the API keys of two folders + +- **GIVEN** a vault user with secrets of several types in five folders +- **WHEN** the user opens the export dialog on /secrets, chooses two folders and the type API key, and exports an encrypted backup +- **THEN** the backup holds exactly the API keys in those two folders and their subfolders + +#### Scenario: A vault user exports a selection + +- **GIVEN** a vault user who selected three secrets in the vault list +- **WHEN** the user chooses Export selected in the selection strip and exports a plaintext CSV after entering the master password +- **THEN** the file holds exactly those three secrets + +### Requirement: Nothing is left out of an export in silence + +When secrets cannot be decrypted while an export is prepared, the export dialog MUST show how many secrets are not included and MUST let the user cancel before any file is written. + +#### Scenario: A secret under a revoked suite + +- **GIVEN** a vault user with one secret whose encryption suite was revoked +- **WHEN** the user opens the export dialog +- **THEN** the dialog says that 1 secret could not be decrypted and is not in this export + +### Requirement: A restored backup keeps types and row positions + +The encrypted backup MUST carry each secret's type name, and for a custom type its label. Restoring a backup MUST give each secret its original type: a system type by name, a custom type by an existing type of the same name, or a new user-scoped custom type created for it. A secret MUST fall back to the default type only when that fails, with a warning on its row. For a backup written before type names were exported, a type id that exists on the instance MUST be used. Every restored or rejected row MUST carry its 1-based position in the backup. A partial backup MUST be announced before the restore commits. + +#### Scenario: A vault user restores a backup on a new instance + +- **GIVEN** a backup holding an authenticator, a passkey, an API key and a secret of the custom type "VPN profile" +- **WHEN** the user restores it through the import wizard on an instance with no "VPN profile" type +- **THEN** each secret is restored with its own type +- **AND** a user-scoped custom type "VPN profile" exists and the restore summary lists it + +#### Scenario: A rejected row names its position + +- **GIVEN** a backup whose seventh secret has an empty name +- **WHEN** the user restores it +- **THEN** the rejected rows list shows row 7 with its reason diff --git a/openspec/changes/portability-export-choice-and-restore-fidelity/tasks.md b/openspec/changes/portability-export-choice-and-restore-fidelity/tasks.md new file mode 100644 index 000000000..4cafc2f32 --- /dev/null +++ b/openspec/changes/portability-export-choice-and-restore-fidelity/tasks.md @@ -0,0 +1,23 @@ +# Tasks: choose what goes into an export, and restore a backup without losing types + +## 1. Export + +- [ ] 1.1 Extend `serializeVault()` with the combined scope, the field deny list, `typeName` and `typeLabel`, and the partial marker; fix the test fixtures to use UUID type ids. Verify: vitest for each scope, each left-out field, a custom type, and a UUID type id resolving to its name. +- [ ] 1.2 Rebuild the scope and fields part of `ExportDialog.vue` (entire vault, folders, types, selection; fields to leave out). Verify: vitest on `buildScope()`, and a Playwright flow export two folders and one type. +- [ ] 1.3 Add Export selected to the bulk strip in `SecretList.vue`, and return and show the skipped count from `decryptAllSecrets()`. Verify: Playwright flow select three items, export, the file holds three; vitest that a secret failing to decrypt is counted. + +## 2. Restore + +- [ ] 2.1 In `backupParser.js`, set `sourceRow` and read `typeName`, `typeLabel` and the partial marker. Verify: vitest on a new-format and an old-format fixture. +- [ ] 2.2 In `import.js`, resolve types by D3's order, creating missing custom types through `POST /api/v1/secret-types`, and list created types and fallbacks in the summary. Verify: vitest for each resolution branch, and a Playwright round trip export then restore on a fresh user keeps every type. + +## 3. Docs + +- [ ] 3.1 Update `docs/gdpr.md` and `docs/importing.md` with the scope, field and restore rules. Verify: docs build. + +## Acceptance criteria + +- An export can be limited to chosen folders, chosen types, or the current selection, and can leave out usernames, additional fields or seeds. +- A backup restored on another user or instance brings back every secret with its type, including custom types. +- Every restored or rejected row names its position in the backup. +- An export that could not include some secrets says how many before it is written. diff --git a/openspec/changes/sharing-federated-recipients/.openspec.yaml b/openspec/changes/sharing-federated-recipients/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/sharing-federated-recipients/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/sharing-federated-recipients/design.md b/openspec/changes/sharing-federated-recipients/design.md new file mode 100644 index 000000000..3eab9496a --- /dev/null +++ b/openspec/changes/sharing-federated-recipients/design.md @@ -0,0 +1,89 @@ +# Design: federated recipients + +## Context + +Code at development `4c214a9d`: + +- Local sharing is client-side re-encryption (ADR-003): the browser asks `GET /api/v1/shares/recipient-certificate` or `POST /api/v1/shares/recipient-certificates` (`lib/Controller/ShareController.php:309` and `:366`) for a local user's active certificate (`lib/Service/RecipientSecretCopyFactory.php` `certificateFor`), encrypts the value, and the server stores the recipient's copy as a `Secret` owned by that recipient (`lib/Service/RecipientSecretCopyService.php:112` to `:113`) linked by a `ShareTarget`. Sync-on-update re-encrypts for every recipient in the editor's browser (`openspec/specs/user-sharing/spec.md`, requirement "Sync on Update"). +- Certificates are issued by the instance's own CA (`lib/Service/CertificateIssuanceService.php:114`); the user's common name is their federated cloud id when available (`lib/Service/EncryptionSuiteProvisioningService.php:331`). +- The public discovery document is `GET /api/v1/app/.well-known/keepiq` (`lib/Controller/DiscoveryController.php:134`, `#[PublicPage]`). +- Keepiq declares Nextcloud 32 to 34 (`appinfo/info.xml:107`). In Nextcloud's public API, `ICloudFederationProviderManager::addCloudFederationProvider()` and `sendNotification()` exist since 14, `sendCloudShare()` since 29, `IOCMDiscoveryService::discover()` since 28, and `IOCMDiscoveryService::getIncomingSignedRequest()` and `requestRemoteOcmEndpoint()` since 33. +- No OCM provider is registered in `lib/AppInfo/Application.php`. + +## Goals / Non-Goals + +**Goals:** + +- Share a secret from one Keepiq instance to a named user on another Keepiq instance, with end-to-end encryption between the two browsers. +- Keep the owner's later changes flowing to the remote copy, and make revocation remove it. +- Give both administrators control over which instances their users exchange secrets with. + +**Non-Goals:** + +- Sharing with someone who has no Keepiq at all. The public link and secret send stay the tools for that. +- Remote recipients editing the shared secret. Remote copies are read-only in this change. +- Federated groups, federated team folders and federated link shares. +- Trusting a partner automatically on first contact. + +## Decisions + +### D1: Partners are an explicit, pinned allowlist + +An administrator adds a partner by its base URL. Keepiq reads the partner's discovery document, which gains a `federation` block (enabled flag and the SHA-256 fingerprint of the partner's Keepiq root certificate), and shows the fingerprint for the administrator to confirm out of band before saving. The partner row records the URL, the pinned fingerprint, and whether outbound and inbound are allowed. With no partner, federation is off. + +Alternative considered: trust on first use for any instance a user names. Rejected: the pinned root is what lets the browser verify a remote certificate at all (D3). + +### D2: Federation needs Nextcloud 33 + +Every server-to-server call in this feature is a signed OCM request: outbound through `IOCMDiscoveryService::requestRemoteOcmEndpoint()`, inbound checked with `getIncomingSignedRequest()`. Both exist from Nextcloud 33. On Nextcloud 32 the partner section says federation needs Nextcloud 33 and stays off. Keepiq's declared range is unchanged. + +### D3: Certificate lookup goes server to server and is verified in the browser + +The owner types `bob@cloud.partner.example`. Keepiq parses it with `ICloudIdManager`, finds the partner (outbound allowed), and calls the partner's new route `GET /api/v1/federation/recipient-certificate?cloudId=` as a signed OCM request. The partner answers only callers on its own inbound allowlist, and only for users whose Keepiq setting allows receiving from other organisations; any other case answers like an unknown user. The answer is Bob's active certificate and its CA chain. + +The owner's browser checks that the chain ends at the pinned partner root and that the certificate's common name is Bob's cloud id, and shows the certificate fingerprint so the owner can compare it with Bob if they want. Only then does it encrypt. + +### D4: Delivery over OCM with a pull of the ciphertext + +The owner's browser encrypts `key`, `login` and `additionalFields` with Bob's certificate and posts them with the plain `name` and `url` to `POST /api/v1/secrets/{id}/federated-shares`. The server stores an outbound share row and sends an OCM share (resource type `keepiq-secret`, share type `user`) with `sendCloudShare()`, carrying a random shared secret and the owner's cloud id, but not the ciphertext. + +Bob's server, in the registered `ICloudFederationProvider::shareReceived()`, checks the sender is an inbound partner, stores a pending inbound row, and notifies Bob. When Bob accepts in "Incoming from other organisations", his server fetches the ciphertext from the sender's `GET /api/v1/federation/shares/{id}` with the shared secret in a signed request, and stores it as a `Secret` owned by Bob with a read-only flag and the sender's cloud id. Bob decrypts it in his browser with his own key, as any secret. + +Alternative considered: putting the ciphertext in the OCM share body. Rejected: a pull lets the receiving server fetch only after acceptance, and lets updates reuse the same route. + +### D5: Updates, revocation and expiry + +When the owner updates the secret, the browser's sync step also encrypts for each federated recipient, using the certificate fetched again through D3, and posts it to the outbound row; the server sends an OCM notification `SHARE_UPDATED`, and the receiving server pulls again (`notificationReceived()`). Revoking sends `SHARE_UNSHARED` and the receiving server deletes the copy. If the partner is removed or the recipient's certificate no longer verifies, the owner is told and the share is suspended until they revoke or re-share. A failed notification is retried by a background job with backoff and shown to the owner after the last attempt. + +### D6: Policy on both sides + +Instance level: the partner allowlist (D1). User level: a personal setting "Receive secrets from other organisations" (default off) that D3 checks. Owner side: the share dialog offers federated recipients only when at least one outbound partner exists. + +## Security and zero-knowledge + +Between instances travel only: OCM share metadata (cloud ids, share id, shared secret, resource type), certificates, and ciphertext encrypted in the owner's browser for the recipient's certificate. Neither server can decrypt a value; the owner's server never holds the recipient's private key and the recipient's server never holds the owner's (ADR-003). + +Stored on the sending side: the outbound share row with the recipient cloud id, partner id, recipient certificate fingerprint, status, the hashed shared secret, and the latest ciphertext for the recipient (needed for the pull). Stored on the receiving side: the inbound row with sender cloud id, partner id, remote share id, the shared secret encrypted with `ICrypto` (the server must present it unattended), status, and the local copy's id. The `name` and `url` of a shared secret are plain on both sides, as they are for local shares. + +What a partner could still do: a malicious partner server could issue a certificate for its own user that the owner's browser accepts, because the owner trusts the partner's root by design. The fingerprint display and the administrator's explicit allowlist are the controls; the owner shares only with instances their administrator approved. + +## Risks / Trade-offs + +- **Two administrators must act before anyone can share.** Deliberate: federation of secrets should never be on by accident. +- **Nextcloud 32 instances cannot federate.** Stated in the admin section. +- **Remote copies lag if a notification fails.** Retries and an owner-visible failure state cover it; the remote copy is never partially updated because the pull replaces it whole. +- **A recipient cannot edit.** Remote editing would need the recipient's browser to encrypt for the owner and every other recipient across instances; a later change. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. The integration test runs two Nextcloud 33 containers on one Docker network, each with Keepiq and a seeded user, set up by the test itself; no fixture data is committed. + +## Migration + +New tables through a new migration step: + +- `keepiq_federation_partners`: id, base_url, root_fingerprint, allow_outbound, allow_inbound, added_by, added_at. +- `keepiq_federated_shares` (outbound): id, source_secret_id, owner_id, recipient_cloud_id, partner_id, recipient_cert_fingerprint, key, login, additional_fields (ciphertext for the recipient), shared_secret_hash, status, created_at, updated_at. +- `keepiq_federated_inbound`: id, recipient_uid, sender_cloud_id, partner_id, remote_share_id, shared_secret_enc, secret_id, status (`pending`, `accepted`, `declined`, `revoked`), received_at. + +`secrets` gains `federated_source` (nullable `STRING(255)`, the sender cloud id) and `read_only` (boolean, default false). The `` in `appinfo/info.xml` must bump. diff --git a/openspec/changes/sharing-federated-recipients/proposal.md b/openspec/changes/sharing-federated-recipients/proposal.md new file mode 100644 index 000000000..74bc3164e --- /dev/null +++ b/openspec/changes/sharing-federated-recipients/proposal.md @@ -0,0 +1,53 @@ +--- +kind: code +--- + +# Share a secret with a user on another Nextcloud instance + +## Why + +Keepiq shares only with users and groups on the same Nextcloud instance. A municipality that works with a partner organisation on its own Nextcloud has to fall back to a password-protected public link, a snapshot that does not follow later changes and is not tied to a person. Nextcloud already federates files between instances through Open Cloud Mesh (OCM); Keepiq does not use it. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| sharing-17 | Share with someone outside the organisation who has their own account elsewhere. | Sharing with an account on a different Nextcloud instance is not supported; a password-protected public link (sharing-14) is the closest substitute. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. Every sharing path targets a local user, a local group, or an anonymous link (`lib/Controller/ShareController.php`, `GroupShareController.php`, `LinkShareController.php`). The only federated cloud id in the code names a certificate's common name (`lib/Service/EncryptionSuiteProvisioningService.php:331`); nothing registers an OCM provider. + +### Demand + +No demand row. + +### Competitors rated yes + +- 1Password: "https://support.1password.com/share-items/ : recipient can save the shared item into their own 1Password account; guest accounts in https://support.1password.com/custom-groups/" +- Keeper: "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#creating-and-sharing : policies 'Share to users outside of the enterprise' and 'Receive items from users outside of the enterprise'" + +## What Changes + +- Administrators list trusted partner instances, pin each partner's Keepiq root certificate fingerprint, and allow outbound, inbound or both. Federation is off until a partner is added. +- A vault owner can share a secret with a federated cloud id (`bob@cloud.partner.example`). Their browser fetches Bob's certificate through their own server from the partner, checks it against the pinned root, shows its fingerprint, and encrypts the secret for Bob. Only ciphertext leaves the browser. +- Keepiq registers an OCM resource type `keepiq-secret`. The sending server announces the share over OCM; the receiving server fetches the ciphertext with the share's shared secret and, once Bob accepts, stores it as a secret in Bob's vault. +- Updates by the owner are re-encrypted in the owner's browser for the remote recipient and announced over OCM; revocation and expiry remove the remote copy. +- Remote copies are read-only for the recipient in this change. +- Nextcloud 33 or later is required for federation, so every server-to-server call is a signed OCM request. + +## Capabilities + +### New Capabilities + +- `federated-sharing`: trusted partner instances, federated certificate lookup, OCM share delivery, acceptance, sync, revocation, and policy. + +### Modified Capabilities + +None. Local sharing stays as specified in `user-sharing`. + +## Impact + +- **Backend**: an OCM provider registered with `OCP\Federation\ICloudFederationProviderManager::addCloudFederationProvider()`, outbound shares through `sendCloudShare()` and `sendNotification()`, partner discovery through `OCP\OCM\IOCMDiscoveryService`, request verification through `IOCMDiscoveryService::getIncomingSignedRequest()`, cloud id parsing through `OCP\Federation\ICloudIdManager`; new controllers and services for partners, federated shares and inbound shares. +- **Frontend**: a federated recipient option in the share dialog, a partner list in the admin settings, and an "Incoming from other organisations" list for accepting shares. +- **Database**: three new tables; a migration and a `` bump. +- **Security**: ciphertext only between instances; certificates checked against a pinned partner root; signed OCM requests; administrator allowlists in both directions. +- **Cross-app**: none. Nextcloud's own federated file sharing is untouched. diff --git a/openspec/changes/sharing-federated-recipients/specs/federated-sharing/spec.md b/openspec/changes/sharing-federated-recipients/specs/federated-sharing/spec.md new file mode 100644 index 000000000..d47712558 --- /dev/null +++ b/openspec/changes/sharing-federated-recipients/specs/federated-sharing/spec.md @@ -0,0 +1,92 @@ +## ADDED Requirements + +### Requirement: Administrators approve and pin partner instances + +The system MUST let an administrator, after Nextcloud password confirmation, add a partner instance by URL, MUST read and show the partner's Keepiq root certificate fingerprint from its discovery document for confirmation, and MUST store the pinned fingerprint with separate outbound and inbound permissions. With no partner, the system MUST NOT offer, send or accept any federated share. On a Nextcloud version below 33 the section MUST explain that federation needs Nextcloud 33 and MUST keep federation off. + +#### Scenario: Administrator adds a partner + +- **GIVEN** an administrator on the federation section of the Keepiq admin settings on Nextcloud 33 +- **WHEN** they add `https://cloud.partner.example`, compare the shown fingerprint with the partner's administrator, and allow outbound and inbound +- **THEN** the partner MUST be stored with the pinned fingerprint and both permissions + +#### Scenario: No partner, no federation + +- **GIVEN** an instance with no partner +- **WHEN** a vault owner opens the share dialog +- **THEN** the dialog MUST NOT offer a federated recipient + +### Requirement: Certificate lookup is signed, allowlisted and verified in the browser + +The system MUST fetch a federated recipient's certificate only from an outbound partner, as a signed OCM request. A partner MUST answer only signed requests from its own inbound partners about users who allow receiving from other organisations, and MUST answer every other case as for an unknown user. The owner's browser MUST refuse a certificate whose chain does not end at the pinned partner root or whose common name is not the recipient's cloud id, and MUST show the certificate fingerprint before encrypting. + +#### Scenario: A verified remote certificate + +- **GIVEN** a vault owner on an instance that pinned `cloud.partner.example`, and `bob@cloud.partner.example` who allows receiving +- **WHEN** the owner enters Bob's cloud id in the share dialog +- **THEN** the browser MUST verify Bob's certificate against the pinned root and show its fingerprint + +#### Scenario: A certificate from another root is refused + +- **GIVEN** a lookup answer whose chain ends at a root other than the pinned one +- **WHEN** the browser checks it +- **THEN** the share dialog MUST refuse to encrypt and say the certificate could not be verified + +#### Scenario: Directory probing learns nothing + +- **GIVEN** an instance that is not an inbound partner of `cloud.partner.example` +- **WHEN** it asks the partner for the certificate of `bob@cloud.partner.example` +- **THEN** the partner MUST answer exactly as it does for a user who does not exist + +### Requirement: Federated shares carry only browser-made ciphertext + +The owner's browser MUST encrypt the value, login and additional fields for the verified recipient certificate and send only that ciphertext with the plain name and URL. The sending server MUST announce the share over OCM with resource type `keepiq-secret` without the ciphertext. The receiving server MUST store an inbound share as pending and notify the recipient, and only after acceptance MUST pull the ciphertext with the share's shared secret in a signed request and store it as a read-only secret owned by the recipient. + +#### Scenario: Bob accepts a shared login + +- **GIVEN** a pending federated share from `alice@cloud.city.example` to Bob +- **WHEN** Bob accepts it under "Incoming from other organisations" +- **THEN** his server MUST pull the ciphertext and store a read-only secret in Bob's vault marked as coming from `alice@cloud.city.example` +- **AND** Bob's browser MUST decrypt it with Bob's own key + +#### Scenario: A non-partner cannot deliver + +- **GIVEN** an OCM share of type `keepiq-secret` from an instance that is not an inbound partner +- **WHEN** Bob's server receives it +- **THEN** it MUST reject the share and store nothing + +### Requirement: Owner updates reach the remote copy and revocation removes it + +When the owner updates a federated shared secret, the owner's browser MUST encrypt the new value for each federated recipient with a freshly verified certificate, and the sending server MUST send an OCM `SHARE_UPDATED` notification after which the receiving server pulls the new ciphertext. Revoking MUST send `SHARE_UNSHARED`, after which the receiving server MUST delete the copy. A share whose partner was removed, or whose recipient certificate no longer verifies, MUST be suspended and shown to the owner. + +#### Scenario: A password change reaches Bob + +- **GIVEN** a federated share accepted by Bob +- **WHEN** Alice changes the password in her vault +- **THEN** Bob's copy MUST show the new password after his server pulls the update + +#### Scenario: Revocation removes Bob's copy + +- **GIVEN** a federated share accepted by Bob +- **WHEN** Alice revokes it +- **THEN** Bob's server MUST delete the copy from Bob's vault + +### Requirement: Remote copies are read-only + +The system MUST refuse update, sync, onward sharing and link-share creation on a secret marked read-only for its owner. + +#### Scenario: Bob cannot edit or pass it on + +- **GIVEN** a read-only federated copy in Bob's vault +- **WHEN** Bob calls `PUT /api/v1/secrets/{id}` or tries to share it +- **THEN** the system MUST refuse the request with a forbidden response + +### Requirement: Users opt in to receiving + +Each user MUST have a personal setting "Receive secrets from other organisations", default off, and the certificate lookup MUST treat a user who has not opted in as unknown. + +#### Scenario: Opted-out user cannot be found + +- **GIVEN** `carol@cloud.partner.example` who has not opted in +- **WHEN** a partner looks up her certificate +- **THEN** the answer MUST be the unknown-user answer diff --git a/openspec/changes/sharing-federated-recipients/tasks.md b/openspec/changes/sharing-federated-recipients/tasks.md new file mode 100644 index 000000000..0e3a1d271 --- /dev/null +++ b/openspec/changes/sharing-federated-recipients/tasks.md @@ -0,0 +1,39 @@ +# Tasks: federated recipients + +## 1. Partners and discovery + +- [ ] 1.1 Add the three federation tables, the `federated_source` and `read_only` columns on `secrets`, a migration step and a `` bump. Verify: a PHPUnit migration test asserts the tables and columns. +- [ ] 1.2 Add a `federation` block (enabled flag, root certificate fingerprint) to the discovery document at `GET /api/v1/app/.well-known/keepiq`. Verify: PHPUnit asserts the fingerprint matches the instance root. +- [ ] 1.3 Add the admin partner section and endpoints (`#[AuthorizedAdminSetting]` plus `#[PasswordConfirmationRequired]`): add by URL, show and confirm the fetched fingerprint, set outbound and inbound, remove; show "needs Nextcloud 33" below 33. Verify: PHPUnit for add, pin and the version gate; vitest for the section. + +## 2. Certificate lookup + +- [ ] 2.1 Add the partner-facing `GET /api/v1/federation/recipient-certificate` that verifies the signed OCM request, answers only inbound partners and users who allow receiving, and otherwise answers as for an unknown user. Verify: PHPUnit for an unsigned request, a non-partner, a user who opted out and an allowed lookup. +- [ ] 2.2 Add the owner-facing lookup that parses the cloud id with `ICloudIdManager`, calls the partner through `IOCMDiscoveryService::requestRemoteOcmEndpoint()`, and returns the certificate chain. Verify: PHPUnit with a mocked discovery service. +- [ ] 2.3 In the share dialog, add the federated recipient option, verify the chain against the pinned root and the common name in the browser, show the fingerprint, and encrypt. Verify: vitest refuses a chain that ends at another root and a mismatched common name. + +## 3. Delivery + +- [ ] 3.1 Register the `keepiq-secret` OCM provider in `Application.php` and send outbound shares with `sendCloudShare()` after `POST /api/v1/secrets/{id}/federated-shares` stores the ciphertext. Verify: PHPUnit asserts the OCM share carries no ciphertext. +- [ ] 3.2 Implement `shareReceived()` (inbound partner check, pending row, notification) and the "Incoming from other organisations" list with accept and decline. Verify: PHPUnit for a non-partner sender and a pending row; vitest for the list. +- [ ] 3.3 On acceptance, pull the ciphertext from the sender's `GET /api/v1/federation/shares/{id}` with the shared secret in a signed request, and store a read-only `Secret` owned by the recipient. Verify: PHPUnit for the pull, the stored flags, and refusal of a wrong shared secret. +- [ ] 3.4 Refuse every write to a `read_only` secret for its owner (update, sync, share onward, link share). Verify: PHPUnit for each refused route. + +## 4. Sync and revocation + +- [ ] 4.1 Extend the browser's sync step to encrypt for federated recipients with a freshly verified certificate, and send `SHARE_UPDATED`; handle it in `notificationReceived()` with a new pull. Verify: vitest for the extra recipient; PHPUnit for the notification handler. +- [ ] 4.2 Revoke with `SHARE_UNSHARED` and delete the remote copy; suspend shares whose partner was removed or whose certificate no longer verifies; retry failed notifications from a background job with backoff. Verify: PHPUnit for revoke, suspend and retry. +- [ ] 4.3 Audit every federated share event on both sides with identifiers only. Verify: PHPUnit asserts no audit metadata holds ciphertext or the shared secret. + +## 5. End to end + +- [ ] 5.1 Add an integration test with two Nextcloud 33 containers: partners pinned on both sides, owner shares with a remote user, the recipient accepts and reads the value in the browser, the owner updates and revokes. Verify: the test passes in a dedicated CI job. + +## Acceptance criteria + +- A vault owner can share a secret with a user on an approved partner instance, and the recipient reads it in their own vault after accepting. +- Only ciphertext made in the owner's browser for the recipient's verified certificate crosses between instances. +- A certificate that does not chain to the pinned partner root is refused in the browser. +- Updates reach the remote copy, and revocation removes it. +- Nothing federates until both administrators add each other as partners, and a user receives only after opting in. +- Federation stays off on Nextcloud 32. diff --git a/openspec/changes/sharing-team-folder-manager-role/.openspec.yaml b/openspec/changes/sharing-team-folder-manager-role/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/sharing-team-folder-manager-role/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/sharing-team-folder-manager-role/design.md b/openspec/changes/sharing-team-folder-manager-role/design.md new file mode 100644 index 000000000..1008024af --- /dev/null +++ b/openspec/changes/sharing-team-folder-manager-role/design.md @@ -0,0 +1,77 @@ +# Design: team-folder manager role + +## Context + +Code at development `4c214a9d`: + +- `lib/Service/TeamFolderQueryService.php:110` `loadOwnedTeamFolder()` refuses anyone but the owner. It guards `unshareFolder` (`lib/Service/TeamFolderService.php:149`), `addMember` (`:222`), `removeMember` (`:314`), `reconcile` (`:387`), `registerFanOutShares` (`:424`), `approveJoin` (`:497`) and `setMemberGrade` (`:582`). +- `setMemberGrade()` accepts `read` or `write` only (`:578`). +- `lib/Db/TeamFolderMember.php:144` `effectiveGrade()` returns `write` for `write` and `read` for anything else, so a stored `manage` would silently act as `read` today. +- `lib/Service/TeamFolderQueryService.php:256` `resolveGrade()` walks the ancestor chain and returns `write` at the first write grant, else `read`. +- Write-grade checks: `lib/Service/ShareService.php:312` (`!== 'write'` hides the recipient list), `lib/Service/ShareSyncService.php:174` and `:211` (`write` or `owner`). +- `addMember()` returns the new users' certificates and the subtree's secret refs; the caller's browser encrypts every secret for every new user and posts them to `registerFanOutShares()`, which accepts rows only for secrets in the subtree and never for the owner (`lib/Service/TeamFolderShareService.php:278` to `:281`). A member already holds a recipient copy of each folder secret (`TeamFolderShareService.php:295`). +- The `team_folder_members.grade` column is `STRING(8)`, default `read` (`lib/Migration/Version001000Date20260908000000.php:732`). +- `src/modals/TeamFolderDialog.vue:65` to `:74` renders the grade select (Read, Write) for the owner only. + +## Goals / Non-Goals + +**Goals:** + +- A team folder can have managers who keep its membership current without the owner. +- Viewer, Editor and Manager as the words people see. +- The owner keeps the last word: only the owner makes or unmakes managers and ends the folder. + +**Non-Goals:** + +- A manager adding their own secrets to the owner's folder. Secrets in a team folder stay owned by the folder owner; moving a secret in stays an owner action. +- Ownership transfer. The existing handover and offboarding paths cover it. +- Per-secret roles, and narrowing a subfolder's grade below an ancestor's (still out of scope, as in the existing spec). +- Managers on user shares or group shares outside team folders. + +## Decisions + +### D1: `manage` is a grade, ranked above `write` + +The membership `grade` takes `read`, `write` or `manage`. `effectiveGrade()` returns the stored value when it is one of the three and `read` otherwise. `resolveGrade()` returns the highest along the ancestor chain with the ranking `read` < `write` < `manage`, stopping early only at `manage`. Every check that asks for `write` accepts `manage` too. + +Alternative considered: a separate `is_manager` flag beside the grade. Rejected: a manager must also be able to edit, so a flag would allow the meaningless pair "read plus manager" and need a second ranking rule. + +### D2: One guard for manageable folders + +`TeamFolderQueryService::loadManageableTeamFolder(teamFolderId, userId)` returns the folder when the caller is the owner or has an effective `manage` grade on it (through its own membership or an ancestor's, per D1). `addMember`, `removeMember`, `setMemberGrade`, `approveJoin`, `reconcile` and `registerFanOutShares` switch to it. `unshareFolder` and team-folder deletion keep `loadOwnedTeamFolder()`. + +Within that guard, a manager who is not the owner is refused when they try to: set or clear `manage` on anyone, remove or change a member whose grade is `manage`, or touch the owner. A manager may remove their own membership. + +### D3: Managers fan out from their own copies + +When a manager adds a member, `addMember()` returns the same recipients and secret refs it returns the owner. The manager's browser decrypts its own recipient copy of each folder secret with its own key and encrypts for each new user; `registerFanOutShares()` accepts the rows under the manage guard with the existing subtree and not-the-owner checks. A secret the manager holds no copy of (for example added after the manager joined and not yet fanned out to them) is skipped and appears in the owner's reconcile as missing, which the owner fills as today. + +### D4: Attribution and visibility + +`memberAdded`, member removal and `gradeChanged` audit events already carry an `actorId`; with managers acting, the actor is the manager. The member list already stores `added_by`; the dialog shows it ("Added by Olga"). No extra notification to the owner in this change; the audit trail and the list are the record. + +### D5: Words in the interface + +`TeamFolderDialog.vue` shows Viewer, Editor and Manager. The owner sees all three options; a manager sees Viewer and Editor and sees Manager rows as read-only. A viewer or editor sees no member controls. + +## Security and zero-knowledge + +No new key material, no new ciphertext type. A manager's fan-out is the same operation the owner performs today: decrypt in the browser with the caller's own key, encrypt for recipient certificates, post ciphertext (ADR-003). The server authorizes on grade metadata only and never decrypts. + +What a manager gains is authority, not keys: they could already read every folder secret as a member. The new risk is a manager adding someone the owner would not; D2 keeps the owner above every manager, and D4 records who did what. + +Stored in plain, as today: membership rows with `grade` and `added_by`. Nothing stored encrypted changes. + +## Risks / Trade-offs + +- **A manager can add a member who then reads every folder secret.** That is the point of the role; the owner chooses managers, and the audit trail names the manager. +- **Fan-out gaps when a manager lacks a copy.** Reported through the owner's reconcile rather than failing the add. +- **An old client that sends only `read` and `write`** keeps working; an older server build that sees `manage` would treat it as `read` (D1's context), so this change ships server and client together. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests create a team folder with an owner, a manager, an editor and a viewer. + +## Migration + +None. The `grade` column already holds up to eight characters, so `manage` fits; no table or column changes and no `` bump. When this change is archived, the note at `openspec/specs/folder-permission-grades/spec.md:82` that calls `manage` out of scope must be updated. diff --git a/openspec/changes/sharing-team-folder-manager-role/proposal.md b/openspec/changes/sharing-team-folder-manager-role/proposal.md new file mode 100644 index 000000000..3a9b07ce8 --- /dev/null +++ b/openspec/changes/sharing-team-folder-manager-role/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# A manager role on team folders + +## Why + +A team folder has one person who can change anything about its membership: the owner. Members are viewers (`read`) or editors (`write`). When the owner is on leave or simply busy, nobody else can add a new colleague, remove a leaver, or promote someone to editor. Every competitor that rates yes lets a shared collection have managers. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| sharing-18 | Give people roles such as manager or viewer on a shared collection. | The only role-like concept is a team-folder membership grade of read or write (folder-permission-grades spec, sharing-09); there is no manager or viewer role vocabulary and no collection concept beyond team folders. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Not built. The row record gives no `note`; the text above is its evidence. `openspec/specs/folder-permission-grades/spec.md:82` names a `manage` grade out of scope for v1, a scope boundary of that change and not a non-goal. Every membership action goes through the owner-only guard `TeamFolderQueryService::loadOwnedTeamFolder()` (`lib/Service/TeamFolderQueryService.php:110`), and `setMemberGrade()` accepts only `read` and `write` (`lib/Service/TeamFolderService.php:578`). + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 libs/common/src/admin-console/models/collections/collection-access-selection.view.ts:9 manage vs :7 readOnly; apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts:108 permission options; bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/CollectionsController.cs:190 PUT access Note: Manager (manage collection), editor and viewer roles per collection ..." +- 1Password: "https://support.1password.com/create-share-vaults-teams/ : Allow Managing versus Allow Viewing per person or group" +- Passbolt: "passbolt/passbolt_api@v5.16.0 plugins/PassboltCe/Folders/config/routes.php:72 folder permissions read, update, owner; passbolt/passbolt_api@v5.16.0 config/routes.php:155 PUT /groups/{id} group managers vs members ..." +- Keeper: "https://docs.keeper.io/enterprise-guide/sharing/nested-share-subfolders : roles Viewer, Share Manager, Content Manager, Content and Share Manager, Full Manager" + +## What Changes + +- Team-folder membership gets a third grade, `manage`, next to `read` and `write`. The interface names them Viewer, Editor and Manager. +- A manager can do what an editor can, and can add and remove Viewers and Editors, change a member between Viewer and Editor, approve group joins, and run the fan-out for new members from their own copies. +- Only the owner can grant or revoke Manager, remove a manager, stop sharing the folder, or delete it. A manager can leave. +- The grade ranking becomes `read` < `write` < `manage` along the ancestor chain. +- Every manager action is audited with the manager as actor, and the member list shows who added whom. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `folder-permission-grades`: renames and modifies "Team-folder membership carries a read or write grade" to include `manage` and the manager's authority, modifies the ancestor-chain ranking, and adds requirements for manager actions, owner-only actions and manager fan-out. + +## Impact + +- **Backend**: a manage-aware guard next to `loadOwnedTeamFolder()` for add, remove, grade, approve-join, reconcile and register-shares; `TeamFolderMember::effectiveGrade()`, `TeamFolderQueryService::resolveGrade()`, `ShareService::listSharesForSecret()` and `ShareSyncService` treat `manage` as at least `write`. +- **Frontend**: `src/modals/TeamFolderDialog.vue` shows Viewer, Editor and Manager and shows member controls to managers. +- **Database**: none. The `grade` column (`STRING(8)`) already fits `manage`; no migration, no `` bump. +- **Security**: no new key material; managers fan out from copies they already hold; the server still never sees plaintext. +- **Cross-app**: none. diff --git a/openspec/changes/sharing-team-folder-manager-role/specs/folder-permission-grades/spec.md b/openspec/changes/sharing-team-folder-manager-role/specs/folder-permission-grades/spec.md new file mode 100644 index 000000000..2c0dcb0cc --- /dev/null +++ b/openspec/changes/sharing-team-folder-manager-role/specs/folder-permission-grades/spec.md @@ -0,0 +1,86 @@ +## RENAMED Requirements + +- FROM: `### Requirement: Team-folder membership carries a read or write grade` +- TO: `### Requirement: Team-folder membership carries a read, write or manage grade` + +## MODIFIED Requirements + +### Requirement: Team-folder membership carries a read, write or manage grade + +The system MUST record a `read` (default), `write` or `manage` grade on every team-folder membership, shown in the interface as Viewer, Editor and Manager. A `read` grade MUST grant exactly the access team-folder-sharing grants today. A `write` grade MUST additionally authorize value updates that propagate to all recipients. A `manage` grade MUST include everything `write` allows and MUST authorize membership management within the limits of the requirement "Only the owner governs managers and the folder itself". Only the folder owner, or a member whose effective grade on the folder is `manage`, MUST be able to set or change a grade. + +#### Scenario: New membership defaults to read + +- **GIVEN** an owner shares a folder without specifying a grade +- **WHEN** the membership is created +- **THEN** the system MUST set the grade to `read` and the member MUST NOT be able to push a value update to the team + +#### Scenario: Non-owner cannot change a grade + +- **GIVEN** a member of a shared folder who is not its owner and whose effective grade is `read` or `write` +- **WHEN** they attempt to change any member's grade +- **THEN** the system MUST reject the request with a forbidden response + +#### Scenario: A manager promotes a viewer to editor + +- **GIVEN** a member Olga with grade `manage` on a team folder, and a member Bob with grade `read` +- **WHEN** Olga calls `PATCH /api/v1/team-folders/{id}/members/{memberId}` for Bob with grade `write` +- **THEN** Bob's grade MUST become `write` + +### Requirement: Effective grade is the highest grade along the ancestor folder chain + +The system MUST compute a member's effective grade for a secret or a team folder as the highest grade granted by any ancestor team folder, with `manage` above `write` above `read`. A subfolder MAY raise the grade; it MUST NOT lower it below any ancestor's grade. + +#### Scenario: Subfolder raises the effective grade + +- **GIVEN** folder F grants member M `read`, and subfolder T of F grants M `write` +- **WHEN** the effective grade for a secret in T is resolved for M +- **THEN** it MUST be `write` + +#### Scenario: An ancestor manager outranks a subfolder editor + +- **GIVEN** folder F grants member M `manage`, and subfolder T of F grants M `write` +- **WHEN** the effective grade for T is resolved for M +- **THEN** it MUST be `manage` + +## ADDED Requirements + +### Requirement: Managers keep the membership current + +A member with an effective `manage` grade MUST be able to add users and groups as Viewers or Editors, remove Viewers and Editors, change a member between `read` and `write`, approve a group join, and run reconcile, on the team folder and its subtree. When a manager adds a member, the manager's browser MUST encrypt each folder secret for the new users from the manager's own recipient copies and post only ciphertext; the system MUST accept those rows under the existing subtree and not-the-owner checks. Secrets the manager holds no copy of MUST be skipped and reported, and MUST appear as missing in the owner's reconcile. + +#### Scenario: A manager adds a colleague while the owner is away + +- **GIVEN** a team folder owned by Anna, with Olga as Manager and three secrets that Olga holds copies of +- **WHEN** Olga adds Bob as a Viewer in the team folder dialog +- **THEN** Bob MUST receive a recipient copy of all three secrets, encrypted in Olga's browser +- **AND** the audit trail MUST record Olga as the actor of the member addition + +#### Scenario: A missing copy is reported, not faked + +- **GIVEN** a manager who holds no copy of one folder secret +- **WHEN** they add a new member +- **THEN** that secret MUST be listed as skipped to the manager +- **AND** the owner's reconcile MUST list the pair as missing + +### Requirement: Only the owner governs managers and the folder itself + +The system MUST refuse, for a member who is not the owner, setting or clearing the `manage` grade, removing or changing a member whose grade is `manage`, changing or removing the owner, stopping sharing of the folder, and deleting the team folder. A manager MUST be able to remove their own membership. + +#### Scenario: A manager cannot create another manager + +- **GIVEN** Olga with grade `manage` and Bob with grade `read` +- **WHEN** Olga tries to set Bob's grade to `manage` +- **THEN** the system MUST reject the request with a forbidden response and Bob's grade MUST stay `read` + +#### Scenario: A manager cannot unshare the folder + +- **GIVEN** Olga with grade `manage` +- **WHEN** she calls `DELETE /api/v1/team-folders/{id}` +- **THEN** the system MUST reject the request and the team folder MUST remain + +#### Scenario: A manager leaves + +- **GIVEN** Olga with grade `manage` +- **WHEN** she removes her own membership +- **THEN** the membership MUST be removed and her derived copies revoked as for any member who leaves diff --git a/openspec/changes/sharing-team-folder-manager-role/tasks.md b/openspec/changes/sharing-team-folder-manager-role/tasks.md new file mode 100644 index 000000000..47e141800 --- /dev/null +++ b/openspec/changes/sharing-team-folder-manager-role/tasks.md @@ -0,0 +1,30 @@ +# Tasks: team-folder manager role + +## 1. Grade model + +- [ ] 1.1 Accept `manage` in `TeamFolderMember::effectiveGrade()` and `TeamFolderService::setMemberGrade()`, and rank `read` < `write` < `manage` in `TeamFolderQueryService::resolveGrade()`. Verify: PHPUnit for each grade and for an ancestor `manage` above a subfolder `read`. +- [ ] 1.2 Make every write check accept `manage` (`ShareService::listSharesForSecret()`, `ShareSyncService` at the two grade checks). Verify: PHPUnit asserts a manager can run a value update fan-out like an editor. + +## 2. Guards + +- [ ] 2.1 Add `TeamFolderQueryService::loadManageableTeamFolder()` and switch add, remove, grade, approve-join, reconcile and register-shares to it; keep unshare and delete on the owner guard. Verify: PHPUnit for owner, manager, editor and viewer on each action. +- [ ] 2.2 Refuse a manager setting or clearing `manage`, removing or changing a manager, or touching the owner; allow a manager to remove their own membership. Verify: PHPUnit for each refusal and for self-removal. +- [ ] 2.3 Accept a manager's fan-out rows in `registerFanOutShares()` with the existing subtree and not-the-owner checks. Verify: PHPUnit asserts accepted rows for a manager and refused rows outside the subtree. + +## 3. Interface + +- [ ] 3.1 Show Viewer, Editor and Manager in `src/modals/TeamFolderDialog.vue`, with member controls for managers limited as in D2 and "Added by" on each member. Verify: vitest renders the owner, manager and viewer views. +- [ ] 3.2 Let a manager's browser run the fan-out from its own copies when adding a member, and report skipped secrets. Verify: vitest with a mocked store asserts the rows come from the manager's copies and skipped ones are listed. +- [ ] 3.3 Add a Playwright flow: the owner makes Olga a manager; Olga adds Bob as a viewer; Bob reads a folder secret; Olga cannot make Bob a manager. Verify: the Playwright spec passes in the E2E job. + +## 4. Audit + +- [ ] 4.1 Assert that member added, member removed and grade changed events carry the manager as actor. Verify: PHPUnit on the audit metadata. + +## Acceptance criteria + +- A team-folder membership can be Viewer (`read`), Editor (`write`) or Manager (`manage`). +- A manager can add and remove viewers and editors, change them between the two, approve group joins and run the fan-out for new members. +- Only the owner can grant or revoke Manager, remove a manager, stop sharing the folder or delete it. +- The effective grade along the ancestor chain ranks `manage` above `write` above `read`. +- The server never decrypts anything for a manager action, and every manager action is audited with the manager as actor. diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/.openspec.yaml b/openspec/changes/sharing-use-only-and-expiring-shares/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/design.md b/openspec/changes/sharing-use-only-and-expiring-shares/design.md new file mode 100644 index 000000000..be31121d1 --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/design.md @@ -0,0 +1,91 @@ +# Design: use-only shares and expiring shares + +## Context + +Code at development `4c214a9d`: + +- A share is a recipient-owned `Secret` copy plus a `ShareTarget` row (`share_targets`: source, target user, copy id, optional group share id and team folder id; `lib/Migration/Version001000Date20260908000000.php:644`). Copies are created by `lib/Service/RecipientSecretCopyService.php:75` (owner type `user`, owner id the recipient, `:112` to `:113`). +- Direct shares from the web app go through `POST /api/v1/shares/register-batch` (`lib/Controller/ShareController.php:278`, `lib/Service/DirectShareRegistrar.php:88`); single and batch creates are `ShareController::create` (`:120`) and `createBatch` (`:193`). Group shares are `lib/Controller/GroupShareController.php:103` over `group_shares` (`:366` in the migration, no expiry column). Team-folder members are `TeamFolderMemberController::addMember` (`:119`) and `setMemberGrade` (`:236`, `PATCH /api/v1/team-folders/{id}/members/{memberId}`), over `team_folder_members` (`:724`). +- Revocation deletes the copy and the target row (`lib/Service/ShareRevocationService.php:92`); team-folder member removal revokes derived shares for users no longer covered (`lib/Service/TeamFolderService.php:313`). +- `secrets.expires_at` already exists and means credential expiry for rotation (`:632`); it is not access expiry. +- Read paths a recipient uses: `SecretMapper::findByOwner` (`lib/Db/SecretMapper.php:115`), `findById` (`:76`), `searchByNameOrUrl` (`:396`, the extension match), `findForUnifiedSearch` (`:425`), and the offline manifest (`lib/Service/OfflineManifestService.php:88`). +- Reveal and copy in the web app: `src/components/PasswordField.vue` (reveal toggle), `src/components/CopyButton.vue`, `src/components/SecretDetailSidebar.vue`, `src/components/VersionHistoryPanel.vue`; exports under `src/export/` and `src/cxf/`. The extension decrypts in `browser-extension/src/background/service-worker.js:113` and copies codes in the popup; the CLI reveals in `cli/main.go` (`show` `:180`, `get` `:203`, `copy`). +- Background job pattern: `lib/BackgroundJob/ExpireSecretRequestsJob.php:51` (`TimedJob`, hourly). + +## Goals / Non-Goals + +**Goals:** + +- An owner can let a colleague sign in with a shared login through the extension without Keepiq showing or copying the password to them. +- An owner can give access until a date, after which the server stops serving the copy and removes it. +- Neither flag can be escaped by sharing the copy onward. +- The product tells the owner honestly what use-only does and does not stop. + +**Non-Goals:** + +- Cryptographic use-only. It cannot exist in a vault where the recipient's device fills the password; see the security section. +- Use-only for `write` or `manage` team-folder grades. Editing a value you cannot see is not offered. +- Expiry on public links and sends, which already have their own. +- Hiding the name, URL or login name of a use-only secret. Only the secret value and the additional fields are hidden. + +## Decisions + +### D1: Two flags, set by the sharer, materialised on the copy + +`use_only` (boolean) and `expires_at` (datetime, nullable) are stored on `share_targets` for direct shares, on `group_shares` (inherited by the group's derived targets), and on `team_folder_members`. A `ShareRestrictionResolver` materialises the effective values onto the recipient copy as `secrets.use_only` and `secrets.access_expires_at`, so every client and read path sees them without a join. It runs on share create and change, group share create and change, membership add, change and removal, and in the expiry job. + +For a copy reached through several grants (a direct share and a team folder, or two memberships), the copy is use-only only when every grant is use-only, and the access end is the latest end date, with no end date winning. The most generous grant wins, as it does for grades. + +Alternative considered: computing the flags at read time with joins. Rejected: the extension match, the offline manifest and the CLI list all read owned rows directly; a column keeps each of them one query. + +### D2: The owner sets both in the dialogs; only the owner changes them + +The share dialog and the team-folder dialog get a "Use only (can sign in, cannot view or copy)" checkbox and an optional "Access ends on" date. `use_only` on a membership is accepted only with grade `read`. A date in the past is refused. The owner, and a team-folder manager where `sharing-team-folder-manager-role` applies, can change both later through `PATCH /api/v1/shares/{id}` and the existing membership `PATCH`. The recipient cannot change either: they are not in the recipient-updatable secret fields. + +### D3: What the clients must refuse for a use-only copy + +Web app: no reveal toggle in `PasswordField.vue`, no copy in `CopyButton.vue` for the value and additional fields, no value in the detail sidebar, no edit dialog, no reveal in version history, no value in any export (backup, CXF, CXP transfer, print), and exclusion from bulk export and bulk share. The name, URL and login name stay visible. A current TOTP code may be shown because signing in needs it; the seed is never shown. + +Extension: fill on a site whose registrable domain matches the copy's URL, with no "fill anyway" on a mismatch; fill only into a field of type `password`; never show or copy the value; never offer the save-or-update prompt for that copy; report each fill to `POST /api/v1/secrets/{id}/used`. + +CLI: `show`, `get key` and `copy key` refuse with "This secret is use-only. Sign in through the Keepiq browser extension." `list` shows it with a use-only marker. + +### D4: What the server refuses for a use-only or expiring copy + +- Any share whose source is such a copy: direct, batch, register-batch, group, team-folder fan-out (the copy is excluded from subtree refs), link share, delegation and handover. +- Recipient-side updates and sync of a use-only copy. +- Version history ciphertext of a use-only copy for its recipient. +- The value ciphertext of a use-only copy in the recipient's GDPR export (metadata stays). + +These refusals are real: they hold even against a modified client. + +### D5: Expiry is enforced on every read, then cleaned up + +Every recipient read path adds `access_expires_at IS NULL OR access_expires_at > now`, so a copy stops being served at its end date, not at the next job run. `ExpireSharesJob` (a `TimedJob` every 15 minutes) revokes expired share targets through `ShareRevocationService`, removes expired memberships through the team-folder removal path, and re-runs the resolver for copies still covered by another grant. The offline manifest carries `accessExpiresAt`; the offline client refuses to decrypt a copy past it and drops it at the next sync. + +### D6: Notifications and honesty + +A day before the end date the recipient gets `share_access_ending`. When access ends, the recipient gets `share_access_ended` and the owner gets a notification that says, for a share that was not use-only, "Bob could see this password. Rotate it if Bob should no longer know it", and for a use-only share, "Bob could not view this password in Keepiq". The share dialog shows, next to the use-only checkbox: "Keepiq's apps will not show or copy the password. Someone with technical skill can still read it from their own device. Rotate it when their access ends." + +## Security and zero-knowledge + +Use-only does not change what the server or the recipient's device holds. The recipient's copy is still encrypted to the recipient's certificate, because the recipient's device must decrypt the password to fill it (ADR-003). A recipient who uses a modified client, a debugger, or their own private key against the API can read it. This is the same limit Bitwarden and 1Password document for their equivalent permission. What use-only guarantees is narrower and real: Keepiq's own apps never display or copy the value, the server refuses every onward path it can see (D4), and each fill is recorded. + +Expiry is server-enforced: after the end date the server never serves the copy's ciphertext to the recipient, then deletes it. It cannot remove what the recipient already learned or what an offline snapshot on their device already holds until that device syncs; D5 and D6 handle both honestly. + +Stored in plain: the two flags on share targets, group shares, memberships and copies. They are access metadata, not secrets. Nothing new is stored encrypted. + +## Risks / Trade-offs + +- **Owners may over-trust use-only.** D6 puts the limit in the dialog and in the end-of-access notice. +- **A use-only login on a site with a non-standard login flow** may not fill; the recipient then cannot sign in and asks the owner. Acceptable for the purpose. +- **Clock skew** between the database and PHP. Comparisons use the database time in queries and the job, so one clock decides. +- **The fifteen minute job interval** does not delay the end of access, because reads already filter (D5); it only delays the cleanup. + +## Seed data + +None. Keepiq owns its tables (ADR-001) and has no OpenRegister register. Tests create an owner, a recipient, a group and a team folder in the PHPUnit and Playwright setups. + +## Migration + +A new migration step adds `use_only` (boolean, default false) and `expires_at` (datetime, nullable) to `keepiq_share_targets`, `keepiq_group_shares` and `keepiq_team_folder_members`, and `use_only` (boolean, default false) and `access_expires_at` (datetime, nullable, indexed) to `keepiq_secrets`. Existing rows stay unrestricted. The `` in `appinfo/info.xml` must bump. diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/proposal.md b/openspec/changes/sharing-use-only-and-expiring-shares/proposal.md new file mode 100644 index 000000000..d5afd2696 --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/proposal.md @@ -0,0 +1,64 @@ +--- +kind: code +--- + +# Use-only shares and shares that end by themselves + +## Why + +Every Keepiq recipient who can use a shared secret can also see and copy it, and every share lasts until the owner revokes it by hand. Organisations want to let a colleague or a temporary worker sign in to a shared account without handing over the password, and to give access for a fixed period, such as a project or a replacement during leave. + +| Row | Capability | What Keepiq does today | +|---|---|---| +| sharing-24 | Let a colleague sign in with a shared login without being able to see or copy the password. | Every recipient who can use a secret can also reveal it. | +| sharing-25 | Share an item with a colleague for a set period, after which their access ends by itself. | A share to a colleague lasts until it is revoked by hand. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +Neither is built. A search for `hidePassword`, `useOnly` or `can_view` in `src` and `lib` finds nothing, and team-folder grades are read or write, both of which reveal (`openspec/specs/folder-permission-grades/spec.md`). `lib/Controller/ShareController.php` has no expiry on a user share; time-bound access exists only for public links (sharing-14) and ownership handover (sharing-11). + +Use-only in a zero-knowledge vault is honest only as a client-enforced control. To fill a password, the recipient's own device must decrypt it. This change says so in the product, specifies exactly what the web app, the browser extension and the CLI must refuse, and adds the server-side refusals that are enforceable (no onward sharing, no recipient edits, no version reveal). Expiry, by contrast, is enforced by the server. + +### Demand + +- sharing-24, tender: https://canadabuys.canada.ca/en/tender-opportunities/25260005 +- sharing-25, changelog: https://github.com/bitwarden/clients/pull/22921 + +### Competitors rated yes + +sharing-24: + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Api/Models/Request/SelectionReadOnlyRequestModel.cs:11 HidePasswords on collection access; bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts:134 ViewExceptPass, :136 EditExceptPass ... The password is still decrypted on the device, so this is a UI control, not a cryptographic one." +- 1Password: "https://support.1password.com/create-share-vaults-teams/ : in 1Password Business a group can use items without revealing or copying passwords when the 'View and Copy Passwords' vault permission is removed; https://support.1password.com/permission-enforcement/ notes this permission is client-enforced." + +sharing-25: + +- Keeper: "https://docs.keeper.io/enterprise-guide/sharing/time-limited-access : share credentials with other Keeper users 'on a temporary basis, automatically revoking access at a specified time'." +- Nextcloud Passwords: "marius-wieschollek/passwords@2026.9.0 src/vue/Components/Sharing/ShareOptionsForm.vue:55 expires date; src/lib/Controller/Api/ShareApiController.php:162 expires; src/lib/Cron/SynchronizeShares.php:133 deleteExpiredShares() Note: Shares take an expiry date and a background job removes them when it passes." + +## What Changes + +- A share to a user or group, and a team-folder membership with grade `read`, can be marked use-only. The recipient's copy carries the flag. +- The web app, the extension and the CLI refuse to show, copy, export or edit the value of a use-only copy. The extension still fills it on the matching site. The share dialog tells the owner plainly that use-only is enforced by Keepiq's own apps, not by cryptography. +- The server refuses any onward sharing from a use-only copy, recipient edits, version reveal for the recipient, and link shares; it records each use the extension reports. +- A share to a user or group, and a team-folder membership, can carry an end date. From that moment the server stops serving the copy, and a background job revokes the share or removes the membership through the existing paths. Recipients are warned a day before; owners are told when access ended, with a rotation hint when the recipient could see the value. +- A copy with an end date cannot be shared onward either, so expiry cannot be escaped. + +## Capabilities + +### New Capabilities + +- `use-only-shares`: use-only flag on shares and memberships, client refusals, server refusals, use recording, and the honest client-enforcement statement. +- `expiring-shares`: end dates on shares and memberships, read-path enforcement, background revocation, notifications, and offline handling. + +### Modified Capabilities + +None. `user-sharing`, `team-folder-sharing` and `folder-permission-grades` keep their requirements; this change adds its own. + +## Impact + +- **Backend**: `ShareController`, `DirectShareRegistrar`, `GroupShareController` and `TeamFolderMemberController` accept `useOnly` and `expiresAt`; a resolver materialises both onto the recipient copy; read paths filter expired copies; share sources refuse flagged copies; an `ExpireSharesJob`; a `POST /api/v1/secrets/{id}/used` route and audit events; notification subjects for ending access. +- **Frontend**: share and team-folder dialogs get the two options; `PasswordField.vue`, `CopyButton.vue`, `SecretDetailSidebar.vue`, the version history, exports and bulk actions respect use-only; the extension popup and worker; the CLI `show`, `get` and `copy`. +- **Database**: new columns on `keepiq_share_targets`, `keepiq_group_shares`, `keepiq_team_folder_members` and `keepiq_secrets`; a migration and a `` bump. +- **Security**: expiry is server-enforced; use-only is client-enforced and documented as such; no key material changes. +- **Cross-app**: none. diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/specs/expiring-shares/spec.md b/openspec/changes/sharing-use-only-and-expiring-shares/specs/expiring-shares/spec.md new file mode 100644 index 000000000..af3d42211 --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/specs/expiring-shares/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: Shares and memberships can carry an end date + +The system MUST let the owner set, change or clear an end date on a user share, a group share and a team-folder membership, and MUST refuse a date in the past. The end date MUST be materialised on each recipient copy as `access_expires_at`; a copy reached through several grants MUST take the latest end date, and a grant without an end date MUST win. The recipient MUST NOT be able to change it. + +#### Scenario: A replacement during leave + +- **GIVEN** a vault owner sharing "Payroll portal" with Carla, who covers during a colleague's leave +- **WHEN** they set "Access ends on" to the colleague's return date +- **THEN** Carla's copy MUST carry that `access_expires_at` + +#### Scenario: A past date is refused + +- **GIVEN** a vault owner in the share dialog +- **WHEN** they submit an end date in the past +- **THEN** the system MUST reject the request with a bad-request response + +### Requirement: The server stops serving an expired copy at its end date + +Every read path that serves a recipient's secrets (the secret list and detail, the extension match, unified search and the offline manifest) MUST exclude a copy whose `access_expires_at` has passed, using the database clock. + +#### Scenario: Access ends on time + +- **GIVEN** Carla's copy with an end date of today at 17:00 +- **WHEN** she opens the secret list at /secrets at 17:01 +- **THEN** "Payroll portal" MUST NOT be listed +- **AND** `GET /api/v1/secrets/{id}` for her copy MUST answer as for an unknown secret + +### Requirement: A background job removes expired access + +A background job running every 15 minutes MUST revoke expired share targets through the existing revocation path, remove expired team-folder memberships through the existing removal path, and recompute the flags of copies still covered by another grant. + +#### Scenario: The copy is deleted after its end + +- **GIVEN** an expired share target +- **WHEN** the job runs +- **THEN** the share target and Carla's copy MUST be deleted + +### Requirement: An expiring copy cannot be shared onward + +The system MUST refuse any share whose source is a copy with an end date, by every path that creates a share, and MUST leave such copies out of team-folder fan-out. + +#### Scenario: Carla cannot extend her own access + +- **GIVEN** Carla's expiring copy +- **WHEN** she tries to share it with her personal account +- **THEN** the system MUST refuse and create no copy + +### Requirement: People are told before and when access ends + +The recipient MUST be notified a day before the end date and when access ends. The owner MUST be notified when access ends; for a share that was not use-only the notice MUST suggest rotating the value because the recipient could see it. + +#### Scenario: Owner gets a rotation hint + +- **GIVEN** a non-use-only share to Carla that just expired +- **WHEN** the job removes it +- **THEN** the owner MUST receive a notification that Carla's access ended and that Carla could see the password + +### Requirement: Offline copies respect the end date + +The offline manifest MUST carry each copy's `accessExpiresAt`, and the offline client MUST refuse to decrypt a copy past it and drop it at the next sync. + +#### Scenario: An offline snapshot past the end date + +- **GIVEN** Carla's offline snapshot holding a copy whose end date has passed +- **WHEN** she unlocks offline and opens it +- **THEN** the web app MUST NOT decrypt it and MUST say her access ended diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/specs/use-only-shares/spec.md b/openspec/changes/sharing-use-only-and-expiring-shares/specs/use-only-shares/spec.md new file mode 100644 index 000000000..e222c6519 --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/specs/use-only-shares/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: Owners can share a secret as use-only + +The system MUST let the owner mark a user share, a group share, or a team-folder membership with grade `read` as use-only, and MUST refuse use-only on a `write` or `manage` membership. The flag MUST be materialised on each recipient copy; a copy reached through several grants MUST be use-only only when every grant is use-only. Only the owner (or a team-folder manager for memberships) MUST be able to change the flag. + +#### Scenario: Owner shares a login as use-only + +- **GIVEN** a vault owner in the share dialog for the secret "Supplier portal" +- **WHEN** they share it with Bob and tick "Use only (can sign in, cannot view or copy)" +- **THEN** Bob's copy MUST carry `useOnly` true + +#### Scenario: A second, unrestricted grant lifts use-only + +- **GIVEN** Bob holds a use-only copy through a direct share +- **WHEN** the same secret reaches Bob through a team folder where he is a `read` member without use-only +- **THEN** Bob's copy MUST carry `useOnly` false + +### Requirement: The share dialog states the limit of use-only + +The share dialog and the team-folder dialog MUST state, next to the use-only option, that Keepiq's apps will not show or copy the value, that someone with technical skill can still read it from their own device, and that the owner should rotate it when access ends. + +#### Scenario: The owner sees the limit before choosing + +- **GIVEN** a vault owner opening the share dialog +- **WHEN** the use-only option is shown +- **THEN** the explanation of its limit MUST be visible next to it + +### Requirement: Keepiq's clients never reveal a use-only value + +For a use-only copy, the web app MUST NOT show or copy the value or additional fields, MUST NOT offer editing or version reveal, and MUST leave the value out of every export and bulk share, while it MAY show the name, URL, login name and a current TOTP code. The browser extension MUST fill it only on a site whose registrable domain matches the copy's URL and only into a password field, MUST NOT show or copy it, MUST NOT offer to save or update it, and MUST report each fill. The CLI MUST refuse `show`, `get key` and `copy key` for it. + +#### Scenario: Web app hides the password + +- **GIVEN** Bob with a use-only copy of "Supplier portal" +- **WHEN** he opens it in the secret list at /secrets +- **THEN** there MUST be no reveal toggle and no copy action for the password +- **AND** the name, URL and login name MUST be shown + +#### Scenario: The extension signs Bob in + +- **GIVEN** Bob with a use-only copy whose URL is `https://portal.supplier.example` +- **WHEN** he chooses it in the extension popup on `portal.supplier.example` +- **THEN** the extension MUST fill the login and password fields and report the fill +- **AND** the popup MUST NOT show or copy the password + +#### Scenario: The extension refuses another site + +- **GIVEN** Bob with the same use-only copy +- **WHEN** he is on `attacker.example.net` +- **THEN** the extension MUST NOT offer or fill the copy + +#### Scenario: The CLI refuses to print it + +- **GIVEN** Bob with a use-only copy +- **WHEN** he runs `keepiq show ` +- **THEN** the CLI MUST refuse and print that the secret is use-only + +### Requirement: The server refuses what it can enforce + +The system MUST refuse any share whose source is a use-only copy (direct, batch, group, team-folder fan-out, link share, delegation and handover), MUST refuse recipient updates and sync of a use-only copy, MUST refuse the recipient's version history ciphertext for it, and MUST leave its value ciphertext out of the recipient's GDPR export. + +#### Scenario: A modified client cannot share it onward + +- **GIVEN** Bob with a use-only copy +- **WHEN** a script with Bob's session calls `POST /api/v1/shares/register-batch` with that copy as source +- **THEN** the system MUST refuse the request and create no copy + +#### Scenario: Bob cannot overwrite it + +- **GIVEN** Bob with a use-only copy +- **WHEN** Bob calls `PUT /api/v1/secrets/{id}/sync` for it +- **THEN** the system MUST refuse the request + +### Requirement: Each use is recorded + +The system MUST provide `POST /api/v1/secrets/{id}/used` for the recipient of a use-only copy, MUST record a `secret.used` audit event with identifiers only, and MUST show it in the owner's activity for the source secret. + +#### Scenario: The owner sees who used the login + +- **GIVEN** Bob filled a use-only copy through the extension +- **WHEN** the owner opens the activity tab of "Supplier portal" +- **THEN** the tab MUST list Bob's use with its time diff --git a/openspec/changes/sharing-use-only-and-expiring-shares/tasks.md b/openspec/changes/sharing-use-only-and-expiring-shares/tasks.md new file mode 100644 index 000000000..d091d95e2 --- /dev/null +++ b/openspec/changes/sharing-use-only-and-expiring-shares/tasks.md @@ -0,0 +1,44 @@ +# Tasks: use-only shares and expiring shares + +## 1. Data and resolver + +- [ ] 1.1 Add the migration step for the new columns on share targets, group shares, team-folder members and secrets, and bump ``. Verify: a PHPUnit migration test asserts the columns and the `access_expires_at` index. +- [ ] 1.2 Add `ShareRestrictionResolver` that materialises `use_only` (all grants use-only) and `access_expires_at` (latest end, none wins) onto each copy, and call it from every share, group share and membership write. Verify: PHPUnit for single grants, mixed grants and removal of the last restricted grant. + +## 2. Setting the flags + +- [ ] 2.1 Accept `useOnly` and `expiresAt` on `ShareController::create`, `createBatch`, `registerBatch` and `GroupShareController::create`, and add `PATCH /api/v1/shares/{id}` for the owner. Verify: PHPUnit refuses a past date and a change by the recipient. +- [ ] 2.2 Accept `useOnly` (with grade `read` only) and `expiresAt` on team-folder member add and `PATCH`. Verify: PHPUnit refuses `useOnly` with `write` or `manage`. +- [ ] 2.3 Add the checkbox, the date and the honest use-only text to the share dialog and the team-folder dialog, with the writing skill. Verify: vitest renders both options and the text. + +## 3. Server refusals and use recording + +- [ ] 3.1 Refuse every share source that is a use-only or expiring copy (direct, batch, register-batch, group, link share, delegation, handover) and exclude such copies from team-folder subtree refs. Verify: PHPUnit for each path. +- [ ] 3.2 Refuse recipient updates and sync of a use-only copy, its version history ciphertext for the recipient, and its value ciphertext in the recipient's GDPR export. Verify: PHPUnit for each refusal. +- [ ] 3.3 Add `POST /api/v1/secrets/{id}/used` (recipient of a use-only copy only) with a whitelisted `secret.used` audit event visible in the owner's activity tab. Verify: PHPUnit for the route guard and the audit metadata. + +## 4. Client refusals + +- [ ] 4.1 Web app: hide reveal and copy of the value and additional fields, the edit dialog and version reveal for use-only copies, and exclude them from exports and bulk share. Verify: vitest for `PasswordField.vue`, `CopyButton.vue`, `SecretDetailSidebar.vue`, `VersionHistoryPanel.vue` and the export builder. +- [ ] 4.2 Extension: strict site match, password-field-only fill, no display or copy, no save prompt, and a `used` report per fill. Verify: vitest in `tests/extension/` for each rule. +- [ ] 4.3 CLI: refuse `show`, `get key` and `copy key` for use-only copies and mark them in `list`. Verify: a Go test with a use-only row. + +## 5. Expiry + +- [ ] 5.1 Filter expired copies on every recipient read path (list, get, extension match, unified search, offline manifest). Verify: PHPUnit asserts an expired copy is not returned by any of them one second after its end. +- [ ] 5.2 Add `ExpireSharesJob` (every 15 minutes) that revokes expired targets, removes expired memberships and re-runs the resolver. Verify: PHPUnit for a direct share, a group-derived share and a membership. +- [ ] 5.3 Add the `share_access_ending` and `share_access_ended` notifications and the owner's end-of-access notice with the rotation hint. Verify: PHPUnit for the subjects and the two owner texts. +- [ ] 5.4 Make the offline client refuse to decrypt a copy past `accessExpiresAt`. Verify: vitest with a snapshot holding an expired copy. + +## 6. End to end + +- [ ] 6.1 Add a Playwright flow: the owner shares a login use-only with a one-day end; the recipient sees no reveal or copy in the web app; after the end date (clock moved in the test) the secret is gone from the recipient's list. Verify: the Playwright spec passes in the E2E job. + +## Acceptance criteria + +- An owner can mark a user share, group share or read-grade team-folder membership as use-only, and give any of them an end date. +- Keepiq's web app, extension and CLI never show, copy, export or edit the value of a use-only copy; the extension still fills it on the matching site. +- The share dialog states that use-only is enforced by Keepiq's apps and can be bypassed by a technically skilled recipient. +- The server refuses every onward share of a use-only or expiring copy, recipient edits of a use-only copy, and its version reveal. +- From its end date the server serves an expired copy on no read path, and the job removes it within 15 minutes. +- The owner is told when access ended, with a rotation hint when the recipient could see the value. diff --git a/openspec/changes/vault-duplicate-finder/design.md b/openspec/changes/vault-duplicate-finder/design.md new file mode 100644 index 000000000..1a0b8bd31 --- /dev/null +++ b/openspec/changes/vault-duplicate-finder/design.md @@ -0,0 +1,50 @@ +# Design: find duplicate items in the vault and merge them + +## Context + +At development `4c214a9d`: + +- `src/store/modules/health.js:109` `analyseVault()` fetches the owner-scoped list and `:159` `loadDecryptedRows()` decrypts each value in the browser, excluding authenticator seeds; the engine runs in a web worker (`src/health/worker.js`, `src/health/engine.js`) and is terminated on lock. +- `src/health/engine.js:97-111` already hashes every value and buckets identical digests to mark reuse. +- `openspec/specs/password-health/spec.md:111-112` forbids any endpoint that accepts scores, digests, reuse data or verdicts. +- The import wizard's duplicate step (`src/store/modules/import.js:58-61`, `:83-89`) compares incoming rows with the vault by name and address. +- `src/views/HealthReportView.vue` renders the categories weak, reused, stale, breached, compromised and rotation (`:95-153`). +- `SecretService::update()` (`lib/Service/SecretService.php:825`) accepts new ciphertext for the owner; `delete()` (`:931`) removes a secret and its shares. +- `src/utils/favicon.js` `extractDomain()` turns a stored address into a host. + +## Goals / Non-Goals + +**Goals** +- Show the user where their vault holds the same credential more than once, and let them collapse it in one guided step. + +**Non-Goals** +- Merging recipients' copies of shared items, or items owned by someone else. +- Merging attachments or version histories. The kept item keeps its own; the others' go with them (to the trash when it exists). +- Fuzzy name matching. Grouping is by host, username and value only. + +## Decisions + +**D1. Group on host, username and value.** Exact duplicate: same host (from `extractDomain()` over `url`), same decrypted username and same decrypted value. Likely duplicate: same host and username, different value. Items without an address are grouped on exact name, username and value only. Alternative: reuse the import wizard's name and address match. Rejected: names differ between browsers ("GitHub" and "github.com") while host and username do not. + +**D2. Detection runs with the health engine.** `src/health/duplicates.js` is a pure function called from the same worker, on rows that now also carry the decrypted username. Nothing new is kept after lock. + +**D3. Merge is update then delete.** The kept item is re-saved with the folded additional fields, encrypted in the browser for the owner's suite; the others are deleted through the existing route, so their shares end as today. With `vault-trash-and-archive` in place the delete is a trash move and the merge can be undone item by item. Alternative: a server-side merge endpoint. Rejected: the server cannot read the fields it would merge. + +**D4. Shared items are allowed but announced.** An owned item that is shared is marked in its group, and choosing to merge it away shows how many people lose access. Recipient copies (rows whose source is someone else's) are never listed. + +## Security and zero-knowledge + +Grouping and merging run in the browser on decrypted data the user already may read. No digest, group or count reaches the server, which keeps `password-health` "No Server-Side Health Knowledge". The merge writes ciphertext through the owner's existing update path; the delete audit events carry identifiers and names only, as today. + +## Risks / Trade-offs + +- Two genuinely different accounts with one username on one host (a personal and a work login on one site with the same email) show as a likely duplicate. Likely duplicates are never pre-selected for merge. +- Before the trash lands, a merge deletes for good. The confirmation says so until then. + +## Seed data + +Keepiq owns its tables (keepiq ADR-001) and has no OpenRegister register. The dev fixture owner `admin` gets two identical logins for `example.org` and one with a different password, so both group kinds appear. + +## Migration + +None. diff --git a/openspec/changes/vault-duplicate-finder/proposal.md b/openspec/changes/vault-duplicate-finder/proposal.md new file mode 100644 index 000000000..1930c097e --- /dev/null +++ b/openspec/changes/vault-duplicate-finder/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +--- + +# Find duplicate items in the vault and merge them + +## Why + +Duplicates are caught only while importing: the import wizard compares each incoming row with the existing vault by name and address and asks whether to skip it or import it as a copy (`src/store/modules/import.js:58-61`, the `duplicates` step). Items that are already duplicated stay duplicated: two imports from two browsers, a login saved once by hand and once by the extension, or a colleague's shared copy next to one's own. Nothing finds them, and nothing merges them. The password health report already sees part of the problem, since it marks values that are reused, but it treats two copies of the same login as two logins with a reused password. + +### Matrix rows (keepiq `openspec/parity/capabilities.json`) + +| row | capability | Keepiq today | +|---|---|---| +| `vault-25` | Find duplicate items already in the vault and merge them. | `no`: duplicates are caught only while importing; nothing finds or merges duplicates already stored | + +### Demand + +- Feature request, https://community.bitwarden.com/t/duplicate-removal-tool-report-including-merge/648 +- The row is in the core area (vault). + +### Competitors rated yes + +No competitor is rated yes on this row. + +## What Changes + +- **A Duplicates section in the password health report.** Run in the browser over the decrypted vault, it groups the user's own secrets into exact duplicates (same address host, same username and same value) and likely duplicates (same address host and same username, different value). +- **Merge.** For a group the user picks the item to keep. Keepiq folds the other items' additional fields into it (the kept item wins on a clash, and a clashing value is kept under a suffixed key), saves the kept item, and deletes the others. When the trash exists (`vault-trash-and-archive`), the others go to the trash and can be restored. +- **Guard rails.** Only items the user owns can be merged. A shared item in a group is marked, and merging it away says that its recipients lose access. Passkey and authenticator items are never grouped. + +## Capabilities + +### New Capabilities + +- `vault-duplicates`: client-side detection of duplicate items in the user's own vault and a guided merge. + +### Modified Capabilities + +- None in delta form. `password-health` keeps its "No Server-Side Health Knowledge" requirement, which this change honours by running entirely in the browser. + +## Impact + +- **Backend**: none. Merge uses `PUT /api/v1/secrets/{id}` for the kept item and the existing delete route for the others. +- **Frontend**: a pure `src/health/duplicates.js`, a Duplicates section in `src/views/HealthReportView.vue`, a `src/modals/DuplicateMergeModal.vue`, and a decrypt step that includes the username. +- **Database**: none. +- **Security**: no digest, grouping or verdict reaches the server; the merge writes only fresh ciphertext through the existing update path. +- **Cross-app**: none. diff --git a/openspec/changes/vault-duplicate-finder/specs/vault-duplicates/spec.md b/openspec/changes/vault-duplicate-finder/specs/vault-duplicates/spec.md new file mode 100644 index 000000000..b8619dac7 --- /dev/null +++ b/openspec/changes/vault-duplicate-finder/specs/vault-duplicates/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Detect duplicates in the browser + +The system MUST detect duplicate secrets among the secrets the user owns, in the browser, while the vault is unlocked, as part of the password health analysis. Secrets with the same address host, the same decrypted username and the same decrypted value MUST be grouped as exact duplicates; secrets with the same host and username and a different value MUST be grouped as likely duplicates. Passkey and authenticator secrets and recipient copies of other people's secrets MUST NOT be grouped. No group, digest or count MUST be sent to the server. + +#### Scenario: A vault user sees duplicate logins + +- **GIVEN** a vault user who owns two logins for `github.com` with the same username and password, saved from two browser imports +- **WHEN** the user opens the password health report at /password-health +- **THEN** the Duplicates section lists the two logins as one exact duplicate group +- **AND** no request to the server carries the group + +### Requirement: Merge a duplicate group + +The system MUST let the user merge a duplicate group by choosing the secret to keep. The kept secret MUST be saved with the other secrets' additional fields folded in, the kept secret's value winning on a clash and a clashing value being kept under a suffixed key, encrypted in the browser for the owner's active suite. The other secrets MUST then be deleted through the existing delete path. Before confirming, the system MUST say how many people lose access through shares of the secrets being removed. Likely duplicates MUST never be pre-selected for merging. + +#### Scenario: A vault user merges an exact duplicate group + +- **GIVEN** an exact duplicate group of two logins, one with an additional field `recovery email` +- **WHEN** the user keeps the other login and confirms the merge +- **THEN** one login remains and it carries the `recovery email` field +- **AND** the removed login no longer appears in the vault list + +#### Scenario: Merging away a shared item is announced + +- **GIVEN** a duplicate group where the item not kept is shared with two colleagues +- **WHEN** the user opens the merge confirmation +- **THEN** the confirmation says that two people lose access diff --git a/openspec/changes/vault-duplicate-finder/tasks.md b/openspec/changes/vault-duplicate-finder/tasks.md new file mode 100644 index 000000000..3ba23fc95 --- /dev/null +++ b/openspec/changes/vault-duplicate-finder/tasks.md @@ -0,0 +1,21 @@ +# Tasks: find duplicate items in the vault and merge them + +## 1. Detection + +- [ ] 1.1 Add `src/health/duplicates.js` returning exact and likely groups from rows with host, username, value and ownership. Verify: vitest for exact, likely, no-address, passkey and authenticator exclusion, and a recipient copy never listed. +- [ ] 1.2 Decrypt the username in `loadDecryptedRows()` and call the detector from the health worker. Verify: vitest that locking the vault drops the groups. + +## 2. Report and merge + +- [ ] 2.1 Add a Duplicates section to `HealthReportView.vue` listing groups with host, username, folder and a shared marker. Verify: Playwright flow on the fixture vault shows one exact and one likely group. +- [ ] 2.2 Add `src/modals/DuplicateMergeModal.vue`: pick the item to keep, preview the folded additional fields, confirm with the count of people who lose access, then update the kept item and delete the others. Verify: vitest on the field folding with a clash, a Playwright flow merge the exact group, and the hydra modal-isolation gate. + +## 3. Docs + +- [ ] 3.1 Document the Duplicates section and the merge rules in `docs/password-health.md`. Verify: docs build. + +## Acceptance criteria + +- The health report lists items that hold the same credential more than once, split into exact and likely duplicates. +- A merge keeps one item with the others' additional fields folded in and removes the rest. +- Nothing about duplicates is sent to the server. diff --git a/openspec/changes/vault-favourites-tags-and-last-used/design.md b/openspec/changes/vault-favourites-tags-and-last-used/design.md new file mode 100644 index 000000000..83e81e16f --- /dev/null +++ b/openspec/changes/vault-favourites-tags-and-last-used/design.md @@ -0,0 +1,50 @@ +# Design: favourites, tags and a last-used sort in the vault list + +## Context + +At development `4c214a9d`: + +- `lib/Db/SecretMapper.php:48-53` `SORTABLE_COLUMNS` is `name`, `url`, `created_at`, `updated_at`; `findByOwner()` (`:115`) and `countByOwner()` (`:261`) filter on owner, folder and type. +- `lib/Service/SecretService.php:1000` `list()` passes those filters from `lib/Controller/SecretController.php` `index()` (`:102-115`). +- `lib/Service/SecretService.php:781` `get()` is the single encrypted-blob fetch and already emits `secret.read` (`:790-799`); list and search never call it. +- The browser extension fills from blob rows cached at match time (`browser-extension/src/background/service-worker.js:105-125` `doFill`), so a fill does not call `get()`. +- `src/views/SecretList.vue:190-233` is the filter menu (type filter and `sortOptions` at `:787-792`); `:294-333` is the bulk selection strip; rows render through `src/components/SecretListItem.vue`. +- Share copies are full `Secret` rows per recipient. `lib/Service/ShareSyncService.php:317-345` `applyRecipientBlob()` copies only `key`, `login` and `additionalFields` onto a copy, so per-row fields added here are never overwritten by an owner's edit. +- Folder names are stored unencrypted as organisational metadata (`openspec/specs/secrets/spec.md:44`). + +## Goals / Non-Goals + +**Goals** +- A person finds the items they use most in one click, labels items across folders, and sorts by what they used last. + +**Non-Goals** +- Shared tags that the owner sets for all recipients. Each holder tags their own row. +- Encrypted tags. See D2. +- A recently-used widget on the dashboard; `vault-21` is building that separately. + +## Decisions + +**D1. Favourite and last-used are columns on the holder's row.** `is_favourite` (boolean, default false) and `last_used_at` (datetime, nullable) on `keepiq_secrets`. Because every recipient has their own row, both are per holder with no extra table. + +**D2. Tags are plain text, like folder names.** A tag is organisation, not a secret, and filtering by tag must run in the list query. Encrypting tags would force the whole vault to be decrypted before any filter. Stored in `keepiq_secret_tags` (`secret_id`, `owner_id`, `tag`, unique on `secret_id` + `tag`), normalised to trimmed lowercase, at most 32 characters, at most 20 tags per item. The tags field says in its help text that tags are not encrypted. Alternative: encrypted tags inside `additionalFields`. Rejected for the filter reason above. + +**D3. Last used means a value was opened or filled.** `SecretService::get()` sets `last_used_at` next to its existing `secret.read` event. A new route `POST /api/v1/extension/used/{id}` (session or paired app password, owner-scoped) lets the extension report a fill after `doFill()` succeeds. Opening the list does not count. Alternative: derive last used from the audit log (`AuditService::recentlyAccessed()`). Rejected: the audit log is pruned by retention and is not indexed for a sort. + +**D4. Filters join the existing query.** `findByOwner()` and `countByOwner()` take `?bool $favourite` and `?string $tag`; the tag filter is an `EXISTS` subquery on `keepiq_secret_tags`. `last_used_at` sorts with nulls last. + +## Security and zero-knowledge + +No secret value is read or stored. The star and the last-used time are metadata about the holder's own row. Tags are plain text by decision D2 and the UI says so. The used-route is owner-scoped: it accepts only ids of rows the caller holds, and returns 404 otherwise, so it cannot probe other users' secrets. + +## Risks / Trade-offs + +- A tag can leak meaning ("board-salaries") to a database reader, the same way a folder name or item name can today. The help text is the mitigation. +- Stamping `last_used_at` on every `get()` adds one write per reveal; it is a single-row update on an indexed key. + +## Seed data + +Keepiq owns its tables (keepiq ADR-001) and has no OpenRegister register. The dev fixture owner `admin` gets two starred secrets, tags `finance` and `on call` on three secrets, and `last_used_at` on four, so the filter and sort have visible results. + +## Migration + +One migration after `Version001000Date20260908000000`: `is_favourite` (boolean, default false) and `last_used_at` (datetime, nullable) on `keepiq_secrets`; table `keepiq_secret_tags` with an index on (`owner_id`, `tag`). `` bumps. The GDPR export (`docs/gdpr.md`) adds tags and favourites to the metadata package; the account deletion cascade deletes the holder's tag rows. diff --git a/openspec/changes/vault-favourites-tags-and-last-used/proposal.md b/openspec/changes/vault-favourites-tags-and-last-used/proposal.md new file mode 100644 index 000000000..54b76f9e4 --- /dev/null +++ b/openspec/changes/vault-favourites-tags-and-last-used/proposal.md @@ -0,0 +1,64 @@ +--- +kind: code +--- + +# Favourites, tags and a last-used sort in the vault list + +## Why + +A vault list in Keepiq can be narrowed by folder and by secret type, and sorted by name, address, date created and date changed (`src/views/SecretList.vue:787-792`, `lib/Db/SecretMapper.php:48-53`). A person with two hundred logins has no way to keep the ten they use daily at hand, no way to label items across folders ("finance", "on call"), and no way to see what they actually used last. `docs/FEATURES.md:74` lists favourite secrets as a V1 feature and `:80` lists tags as an Enterprise feature; neither is built. + +The three rows share one screen, the secret list and its filter menu, and one service, the paged list query, so they are one change. + +### Matrix rows (keepiq `openspec/parity/capabilities.json`) + +| row | capability | Keepiq today | +|---|---|---| +| `vault-09` | Mark items as favourites and filter on them. | `no`: no favourites concept on the entity, store or UI | +| `vault-10` | Label items with tags and filter by tag. | `no`: no tag storage, chip UI or filter | +| `vault-24` | Sort items by date added, date changed or date last used. | `partial`: name, date created and date updated sort; there is no last-used timestamp | + +For `vault-24` the missing half is sorting by date last used. Sorting by date added and date changed is built. + +### Demand + +- `vault-24`: feature request, https://community.bitwarden.com/t/sorting-options-by-date-of-modification-addition-last-use-etc/2484 +- `vault-09`, `vault-10`: no demand row. Both are in the core area (vault) with five and three competitors rating yes. + +### Competitors rated yes + +- `vault-09`, Bitwarden: "libs/common/src/vault/models/view/cipher.view.ts:46 favorite flag; libs/vault/src/services/vault-filter.service.ts:130 'favorites' filter ... Favourite flag per item and a favourites filter in every client." +- `vault-09`, 1Password: "select Add to Favorites... select Favorites in the sidebar" (https://support.1password.com/favorites-tags/). +- `vault-09`, Passbolt: "config/routes.php:81 POST /favorites/resource/{foreignId} ... DisplayResourcesList.js:156 CellFavorite star, ... ResourceWorkspaceContext.js:929 FAVORITE filter." +- `vault-09`, Keeper: "Record Favorites are used to easily identify your most frequently used records. Right-click on a record and select Add to Favorites" (https://docs.keeper.io/user-guides/web-vault#favorites). +- `vault-09`, Nextcloud Passwords: "src/vue/Section/Favorites.vue:32 API.findPasswords({favorite: true}) ... Favourite flag on passwords and folders, with a Favorites section that filters on it." +- `vault-10`, 1Password: "no limit the number of tags", "choose a tag in the sidebar" to filter (https://support.1password.com/favorites-tags/). +- `vault-10`, Passbolt: "plugins/PassboltEe/Tags/config/routes.php:28 POST /tags/{id} ... ResourceWorkspaceContext.js:924 TAG filter, :977 searchByTag ... Tags are a Pro plugin." +- `vault-10`, Nextcloud Passwords: "src/vue/Section/Tags.vue:51 find passwords by tag; src/vue/Dialog/CreatePassword/TagsField.vue ... Tags are first class objects, set in the password dialog or by batch, and the Tags section lists passwords per tag." +- `vault-24`: no competitor rated yes. + +## What Changes + +- **Favourites.** A star on each list row and in the secret detail sidebar marks the item as a favourite for the person who holds it. A Favourites filter in the list's filter menu shows only starred items. +- **Tags.** The create and edit dialogs get a tags field. Tags show as chips on list rows. The filter menu lists the holder's tags; picking one narrows the list. The bulk strip gets Add tag and Remove tag. +- **Last used.** Keepiq records when the holder last opened a secret's value in the web app or filled it from the browser extension, and the sort menu gets Last used. +- All three are per holder: a recipient's copy of a shared secret carries its own star, tags and last-used time, and the owner's changes never overwrite them. + +## Capabilities + +### New Capabilities + +- `vault-list-organisation`: favourites, tags and a last-used sort on the vault list, per holder. + +### Modified Capabilities + +- None in delta form. The `secrets` list requirement (`openspec/specs/secrets/spec.md`, list and pagination) keeps its sort columns and gains one through this change's own requirement. + +## Impact + +- **Backend**: columns `is_favourite` and `last_used_at` on `keepiq_secrets`; a new table `keepiq_secret_tags`; `SecretMapper::findByOwner()` and `countByOwner()` learn a favourite and a tag filter; `SORTABLE_COLUMNS` gains `last_used_at`; `SecretService::get()` stamps `last_used_at`; a new extension route records a fill. +- **Frontend**: star toggle on `SecretListItem.vue` and the detail sidebar, tags field in `SecretCreateDialog.vue` and `SecretEditDialog.vue`, filter and sort options in `SecretList.vue`, bulk tag actions. +- **Browser extension**: after a fill, `service-worker.js` reports the used secret id. +- **Database**: one migration, `` bump. +- **Security**: tags are stored in plain text, like folder names, and the proposal says so to the user in the tags field help text. No secret value is involved. +- **Cross-app**: none. diff --git a/openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md b/openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md new file mode 100644 index 000000000..0683ac639 --- /dev/null +++ b/openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Favourite items per holder + +The system MUST let the holder of a secret, owner or recipient, mark it as a favourite through `PUT /api/v1/secrets/{id}/favourite` and from a star on the secret list row and in the secret detail sidebar. The flag MUST belong to the holder's own row: marking a shared copy MUST NOT change the owner's row or any other recipient's row, and an owner's edit of the secret MUST NOT clear a recipient's flag. The secret list MUST offer a Favourites filter that shows only the holder's favourites. + +#### Scenario: A vault user stars a login and filters on favourites + +- **GIVEN** a vault user with 120 secrets on the secret list at /secrets +- **WHEN** the user clicks the star on two secrets and picks Favourites in the filter menu +- **THEN** the list shows exactly those two secrets +- **AND** the filter button shows that a filter is active + +#### Scenario: A recipient's star survives the owner's edit + +- **GIVEN** a colleague who starred their copy of a shared secret +- **WHEN** the owner changes the secret's password +- **THEN** the colleague's copy is still starred + +### Requirement: Tags per holder + +The system MUST let the holder of a secret set tags on it in the create and edit dialogs, through `PUT /api/v1/secrets/{id}/tags`, and in bulk from the selection strip. Tags MUST be trimmed, lowercased, at most 32 characters each and at most 20 per secret. Tags MUST be stored in plain text and the tags field MUST say so. The secret list MUST show tags as chips on each row and MUST offer the holder's tags in the filter menu; picking a tag MUST narrow the list to secrets with that tag. + +#### Scenario: A vault user labels items across folders and filters by tag + +- **GIVEN** a vault user with secrets in three folders +- **WHEN** the user tags one secret in each folder with `on call` and picks `on call` in the filter menu +- **THEN** the list shows those three secrets and no others + +#### Scenario: A vault user removes a tag in bulk + +- **GIVEN** three secrets tagged `finance` +- **WHEN** the user selects them and chooses Remove tag `finance` in the selection strip +- **THEN** none of the three shows the `finance` chip and the tag no longer appears in the filter menu + +### Requirement: Sort by date last used + +The system MUST record `last_used_at` on the holder's row when the holder opens the secret's value through `GET /api/v1/secrets/{id}` or fills it from the browser extension, which reports the fill through `POST /api/v1/extension/used/{id}`. The used-route MUST return 404 for a secret the caller does not hold. Loading the list or searching MUST NOT change `last_used_at`. The secret list MUST offer Last used in its sort options, with never-used secrets last. + +#### Scenario: A vault user sorts by last used + +- **GIVEN** a vault user who opened secret A yesterday and filled secret B from the extension an hour ago +- **WHEN** the user picks Last used in the sort menu of the secret list +- **THEN** B is first and A is second +- **AND** secrets never opened or filled come after all used ones + +#### Scenario: The used-route cannot probe another user's secret + +- **GIVEN** a paired browser extension of one user +- **WHEN** it calls `POST /api/v1/extension/used/{id}` with the id of another user's secret +- **THEN** the response is 404 and nothing is recorded diff --git a/openspec/changes/vault-favourites-tags-and-last-used/tasks.md b/openspec/changes/vault-favourites-tags-and-last-used/tasks.md new file mode 100644 index 000000000..eba825a56 --- /dev/null +++ b/openspec/changes/vault-favourites-tags-and-last-used/tasks.md @@ -0,0 +1,29 @@ +# Tasks: favourites, tags and a last-used sort in the vault list + +## 1. Data + +- [ ] 1.1 Add the migration (`is_favourite`, `last_used_at`, table `keepiq_secret_tags`), the entity fields, a `SecretTag` entity and mapper; bump ``. Verify: PHPUnit on the mapper and `occ migrations:status keepiq`. +- [ ] 1.2 Extend `SecretMapper::findByOwner()` and `countByOwner()` with the favourite and tag filters and add `last_used_at` (nulls last) to `SORTABLE_COLUMNS`. Verify: PHPUnit for each filter and the sort order. + +## 2. API + +- [ ] 2.1 Add `PUT /api/v1/secrets/{id}/favourite` (body `{favourite: bool}`), `PUT /api/v1/secrets/{id}/tags` (body `{tags: string[]}`), `GET /api/v1/tags` (the holder's distinct tags with counts) and the `favourite` and `tag` query parameters on `GET /api/v1/secrets`. Verify: hydra route-auth and no-admin-idor gates, PHPUnit for tag normalisation and the 20-tag cap. +- [ ] 2.2 Stamp `last_used_at` in `SecretService::get()` and add `POST /api/v1/extension/used/{id}`. Verify: PHPUnit that `get()` stamps the time and that the used-route returns 404 for a secret the caller does not hold. +- [ ] 2.3 Add tags and favourites to the GDPR metadata export and the account deletion cascade. Verify: PHPUnit on `AccountDeletionService` and the GDPR package. + +## 3. Frontend + +- [ ] 3.1 Add the star toggle to `SecretListItem.vue` and the detail sidebar, and a Favourites option to the filter menu in `SecretList.vue`. Verify: vitest on the secret store action and a Playwright flow star, filter, unstar. +- [ ] 3.2 Add a tags field (with the not-encrypted help text) to the create and edit dialogs, tag chips on list rows, the tag list in the filter menu, and Add tag and Remove tag in the bulk strip. Verify: Playwright flow tag two items, filter on the tag, remove it in bulk. +- [ ] 3.3 Add Last used to `sortOptions`. Verify: Playwright flow open a secret, sort by last used, it is first. + +## 4. Browser extension + +- [ ] 4.1 After a successful `doFill()`, call the used-route. Verify: extension unit test that a fill posts the id once and a failed fill posts nothing. + +## Acceptance criteria + +- A person stars an item and the Favourites filter shows only starred items. +- A person tags items, sees the tags as chips, filters on a tag and removes a tag in bulk. +- Sorting by Last used puts the item most recently opened or filled first. +- A recipient's star, tags and last-used time are theirs alone and survive the owner's edits. diff --git a/openspec/changes/vault-item-clone-preview-and-print/design.md b/openspec/changes/vault-item-clone-preview-and-print/design.md new file mode 100644 index 000000000..1aee1aa87 --- /dev/null +++ b/openspec/changes/vault-item-clone-preview-and-print/design.md @@ -0,0 +1,51 @@ +# Design: clone an item, preview an attachment, print a login or show it as a QR code + +## Context + +At development `4c214a9d`: + +- `src/components/SecretDetailSidebar.vue:56-113` has the action row (Edit `:62`, Share `:74`, a More menu with Move `:87` and Delete `:96`, Close `:107`). Dialogs open through `cnOpenModal()` (`:1499`, `:1516`, `:1534`, `:1550`) with keys from `src/registry.js` (`'secret-create'` at `:76`). +- `src/dialogs/SecretCreateDialog.vue:212-224` props are `folderId` and `onSaved`; `data()` (`:226-240`) starts every field empty. It encrypts with the user's active suite before `POST /api/v1/secrets` (`src/store/modules/secret.js:348,385`). +- `src/components/AttachmentPanel.vue:22-60` lists attachments with Download and Delete; `src/store/modules/attachment.js:251-281` `download()` fetches `GET /api/v1/attachments/{id}/blob`, unwraps the file key, decrypts with AES and triggers a download from an object URL. The attachment's decrypted `contentType` and `filename` are known. +- `src/crypto/reauth.js:117` `verifyMasterPassword()` is the client-side proof of knowledge used before a plaintext export (`src/dialogs/ExportDialog.vue:130`). +- The only print today is `src/dialogs/ComplianceSnapshotDialog.vue:201`. +- The admin limit `attachment_max_bytes` (`lib/Service/AdminSettingsService.php:166`) caps attachment size. + +## Goals / Non-Goals + +**Goals** +- Clone in two clicks with nothing sensitive leaking to the new item that the user did not see. +- Look at an attachment without writing it to disk. +- Hand a password to a device without Keepiq, deliberately. + +**Non-Goals** +- Copying attachments or passkeys into a clone. A passkey credential is bound to one item and must not be duplicated; attachments would need a re-encryption of every file. +- Previews of office documents or archives. +- A server-side PDF. + +## Decisions + +**D1. Clone is a prefilled create, not a server copy.** `SecretCreateDialog.vue` gets a `prefill` prop; the sidebar passes the decrypted fields it already holds. Saving is an ordinary create, so the new item gets a fresh id, its own ciphertext, no shares and no history. Alternative: a server-side `POST /secrets/{id}/clone`. Rejected: the server cannot re-encrypt, and copying ciphertext would tie two items to one blob. + +**D2. Preview only safe types, only from memory.** Images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`), `application/pdf` and `text/plain`. The decrypted bytes become a `Blob` and an object URL, shown in an ``, a sandboxed `